507 lines
18 KiB
Dart
507 lines
18 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:shelf/shelf.dart';
|
|
import 'package:shelf/shelf_io.dart' as shelf_io;
|
|
import 'package:test/test.dart';
|
|
|
|
import '../../bin/domain/repositories/album_repository.dart';
|
|
import '../../bin/domain/repositories/photo_repository.dart';
|
|
import '../../bin/middleware/exception_handler.dart';
|
|
import '../../bin/router.dart';
|
|
import '../../bin/util/vips.dart';
|
|
|
|
/// Minimal valid PNG (100x100) for test fixtures.
|
|
List<int> _createTestPng({int width = 100, int height = 100}) {
|
|
final buffer = BytesBuilder();
|
|
// PNG signature
|
|
buffer.addByte(0x89);
|
|
buffer.addByte(0x50);
|
|
buffer.addByte(0x4E);
|
|
buffer.addByte(0x47);
|
|
buffer.addByte(0x0D);
|
|
buffer.addByte(0x0A);
|
|
buffer.addByte(0x1A);
|
|
buffer.addByte(0x0A);
|
|
// IHDR chunk length (13 bytes)
|
|
buffer.addByte(0x00);
|
|
buffer.addByte(0x00);
|
|
buffer.addByte(0x00);
|
|
buffer.addByte(0x0D);
|
|
// IHDR chunk type
|
|
buffer.addByte(0x49);
|
|
buffer.addByte(0x48);
|
|
buffer.addByte(0x44);
|
|
buffer.addByte(0x52);
|
|
// Width (big-endian)
|
|
buffer.addByte((width >> 24) & 0xFF);
|
|
buffer.addByte((width >> 16) & 0xFF);
|
|
buffer.addByte((width >> 8) & 0xFF);
|
|
buffer.addByte(width & 0xFF);
|
|
// Height (big-endian)
|
|
buffer.addByte((height >> 24) & 0xFF);
|
|
buffer.addByte((height >> 16) & 0xFF);
|
|
buffer.addByte((height >> 8) & 0xFF);
|
|
buffer.addByte(height & 0xFF);
|
|
// Rest of IHDR
|
|
buffer.addByte(0x08);
|
|
buffer.addByte(0x02);
|
|
buffer.addByte(0x00);
|
|
buffer.addByte(0x00);
|
|
buffer.addByte(0x00);
|
|
// CRC (dummy)
|
|
buffer.addByte(0x00);
|
|
buffer.addByte(0x00);
|
|
buffer.addByte(0x00);
|
|
buffer.addByte(0x00);
|
|
// IEND
|
|
buffer.addByte(0x00);
|
|
buffer.addByte(0x00);
|
|
buffer.addByte(0x00);
|
|
buffer.addByte(0x00);
|
|
buffer.addByte(0x49);
|
|
buffer.addByte(0x45);
|
|
buffer.addByte(0x4E);
|
|
buffer.addByte(0x44);
|
|
buffer.addByte(0xAE);
|
|
buffer.addByte(0x42);
|
|
buffer.addByte(0x60);
|
|
buffer.addByte(0x82);
|
|
return buffer.toBytes();
|
|
}
|
|
|
|
/// Creates a fresh in-memory server with isolated temp directories.
|
|
///
|
|
/// This is the key optimization over the old approach:
|
|
/// - No subprocess spawning (fast startup)
|
|
/// - Random port via port 0 (no port conflicts)
|
|
/// - Per-test temp directories (no data pollution)
|
|
/// - Reusable HTTP client (connection pooling)
|
|
class TestServer {
|
|
late final Directory dataDir;
|
|
late final Directory cacheDir;
|
|
late final HttpServer server;
|
|
late final http.Client client;
|
|
late final String baseUrl;
|
|
|
|
/// Start the server with the given test data (if any).
|
|
///
|
|
/// [testAlbums] is a map of album name -> list of (filename, fileData).
|
|
static Future<TestServer> start({
|
|
Map<String, List<(String, List<int>)>>? testAlbums,
|
|
}) async {
|
|
final s = TestServer._();
|
|
s.dataDir = await Directory.systemTemp.createTemp('itest_data_');
|
|
s.cacheDir = await Directory.systemTemp.createTemp('itest_cache_');
|
|
|
|
// Create test data if provided
|
|
if (testAlbums != null) {
|
|
for (final entry in testAlbums.entries) {
|
|
final albumDir = Directory(p.join(s.dataDir.path, entry.key));
|
|
await albumDir.create(recursive: true);
|
|
for (final (name, data) in entry.value) {
|
|
File(p.join(albumDir.path, name)).writeAsBytesSync(data);
|
|
}
|
|
}
|
|
}
|
|
|
|
final router = AppRouter(
|
|
albumRepository: AlbumRepository(basePath: s.dataDir.path),
|
|
photoRepository: PhotoRepository(s.dataDir.path),
|
|
vips: Vips(cacheDir: s.cacheDir.path),
|
|
);
|
|
|
|
final handler = Pipeline()
|
|
.addMiddleware(exceptionHandler())
|
|
.addHandler(router.createRouter().call);
|
|
|
|
s.server = await shelf_io.serve(
|
|
handler,
|
|
InternetAddress.loopbackIPv4,
|
|
0, // Random available port
|
|
);
|
|
s.client = http.Client();
|
|
s.baseUrl = 'http://localhost:${s.server.port}';
|
|
return s;
|
|
}
|
|
|
|
TestServer._();
|
|
|
|
Future<void> stop() async {
|
|
client.close();
|
|
await server.close(force: true);
|
|
if (await dataDir.exists()) {
|
|
await dataDir.delete(recursive: true);
|
|
}
|
|
if (await cacheDir.exists()) {
|
|
await cacheDir.delete(recursive: true);
|
|
}
|
|
}
|
|
|
|
Future<http.Response> get(String path) =>
|
|
client.get(Uri.parse('$baseUrl$path'));
|
|
}
|
|
|
|
void main() {
|
|
group('Integration tests (in-memory server)', () {
|
|
late TestServer server;
|
|
|
|
tearDown(() async {
|
|
await server.stop();
|
|
});
|
|
|
|
group('Root endpoint', () {
|
|
test('GET /api/v1/ returns Hello World', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/');
|
|
expect(response.statusCode, 200);
|
|
expect(response.body, 'Hello, World!\n');
|
|
});
|
|
});
|
|
|
|
group('Echo endpoint', () {
|
|
test('GET /api/v1/echo/<message> returns the message', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/echo/hello');
|
|
expect(response.statusCode, 200);
|
|
expect(response.body, 'hello\n');
|
|
});
|
|
|
|
test('GET /api/v1/echo/<message> with special characters', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/echo/test%20message');
|
|
expect(response.statusCode, 200);
|
|
expect(response.body, contains('test'));
|
|
});
|
|
});
|
|
|
|
group('Album endpoints', () {
|
|
test('GET /api/v1/album returns empty list when no albums', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/album');
|
|
expect(response.statusCode, 200);
|
|
expect(response.headers['content-type'], contains('application/json'));
|
|
final albums = jsonDecode(response.body) as List;
|
|
expect(albums, isEmpty);
|
|
});
|
|
|
|
test('GET /api/v1/album returns albums when data exists', () async {
|
|
server = await TestServer.start(
|
|
testAlbums: {
|
|
'vacation': [('photo.png', _createTestPng())],
|
|
'birthday': [('photo2.png', _createTestPng())],
|
|
},
|
|
);
|
|
final response = await server.get('/api/v1/album');
|
|
expect(response.statusCode, 200);
|
|
final albums = jsonDecode(response.body) as List;
|
|
expect(albums.length, 2);
|
|
final names = albums.map((a) => a['name'] as String).toSet();
|
|
expect(names, containsAll(['vacation', 'birthday']));
|
|
});
|
|
|
|
test('GET /api/v1/album/<id> with invalid id returns 400', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/album/invalid_id');
|
|
expect(response.statusCode, 400);
|
|
expect(response.body, contains('Invalid album id'));
|
|
});
|
|
|
|
test('GET /api/v1/album/<id> with non-existent id returns 404', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/album/999999');
|
|
expect(response.statusCode, 404);
|
|
expect(response.body, contains('Album not found'));
|
|
});
|
|
|
|
test('GET /api/v1/album/<id> returns album details', () async {
|
|
server = await TestServer.start(testAlbums: {'my_album': []});
|
|
final listResponse = await server.get('/api/v1/album');
|
|
final albums = jsonDecode(listResponse.body) as List;
|
|
final albumId = albums.first['id'];
|
|
|
|
final response = await server.get('/api/v1/album/$albumId');
|
|
expect(response.statusCode, 200);
|
|
final album = jsonDecode(response.body) as Map;
|
|
expect(album['id'], albumId);
|
|
expect(album['name'], 'my_album');
|
|
});
|
|
|
|
test(
|
|
'GET /api/v1/album/<id>/photo with invalid id returns 400',
|
|
() async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/album/invalid_id/photo');
|
|
expect(response.statusCode, 400);
|
|
expect(response.body, contains('Invalid album id'));
|
|
},
|
|
);
|
|
|
|
test('GET /api/v1/album/<id>/photo returns photos', () async {
|
|
server = await TestServer.start(
|
|
testAlbums: {
|
|
'photo_album': [
|
|
('image.png', _createTestPng(width: 640, height: 480)),
|
|
],
|
|
},
|
|
);
|
|
final listResponse = await server.get('/api/v1/album');
|
|
final albums = jsonDecode(listResponse.body) as List;
|
|
final albumId = albums.first['id'];
|
|
|
|
final response = await server.get('/api/v1/album/$albumId/photo');
|
|
expect(response.statusCode, 200);
|
|
final photos = jsonDecode(response.body) as List;
|
|
expect(photos, isNotEmpty);
|
|
expect(photos.first['fileName'], 'image.png');
|
|
expect(photos.first['mimeType'], 'image/png');
|
|
expect(photos.first['width'], 640);
|
|
expect(photos.first['height'], 480);
|
|
});
|
|
|
|
test(
|
|
'GET /api/v1/album/<id>/photo returns empty for empty album',
|
|
() async {
|
|
server = await TestServer.start(testAlbums: {'empty_album': []});
|
|
final listResponse = await server.get('/api/v1/album');
|
|
final albums = jsonDecode(listResponse.body) as List;
|
|
final albumId = albums.first['id'];
|
|
|
|
final response = await server.get('/api/v1/album/$albumId/photo');
|
|
expect(response.statusCode, 200);
|
|
final photos = jsonDecode(response.body) as List;
|
|
expect(photos, isEmpty);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('Photo endpoints', () {
|
|
test('GET /api/v1/photo/<id> with invalid id returns 400', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/photo/abc');
|
|
expect(response.statusCode, 400);
|
|
expect(response.body, contains('Invalid photo id'));
|
|
});
|
|
|
|
test('GET /api/v1/photo/<id> with non-existent id returns 404', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/photo/12345');
|
|
expect(response.statusCode, 404);
|
|
expect(response.body, contains('Photo not found'));
|
|
});
|
|
|
|
test('GET /api/v1/photo/<id> returns photo metadata', () async {
|
|
server = await TestServer.start(
|
|
testAlbums: {
|
|
'photos': [('photo.png', _createTestPng(width: 800, height: 600))],
|
|
},
|
|
);
|
|
final listResponse = await server.get('/api/v1/album');
|
|
final albums = jsonDecode(listResponse.body) as List;
|
|
final albumId = albums.first['id'];
|
|
|
|
final photosResponse = await server.get('/api/v1/album/$albumId/photo');
|
|
final photos = jsonDecode(photosResponse.body) as List;
|
|
final photoId = photos.first['id'];
|
|
|
|
final response = await server.get('/api/v1/photo/$photoId');
|
|
expect(response.statusCode, 200);
|
|
final photo = jsonDecode(response.body) as Map;
|
|
expect(photo['fileName'], 'photo.png');
|
|
expect(photo['width'], 800);
|
|
expect(photo['height'], 600);
|
|
expect(photo['mimeType'], 'image/png');
|
|
expect(photo, containsPair('createdAt', isA<String>()));
|
|
});
|
|
|
|
test('GET /api/v1/photo/<id>/file with invalid id returns 400', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/photo/abc/file');
|
|
expect(response.statusCode, 400);
|
|
expect(response.body, contains('Invalid photo id'));
|
|
});
|
|
|
|
test(
|
|
'GET /api/v1/photo/<id>/file with non-existent id returns 404',
|
|
() async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/photo/12345/file');
|
|
expect(response.statusCode, 404);
|
|
expect(response.body, contains('Photo not found'));
|
|
},
|
|
);
|
|
|
|
test('GET /api/v1/photo/<id>/file returns file content', () async {
|
|
final testData = _createTestPng(width: 100, height: 100);
|
|
server = await TestServer.start(
|
|
testAlbums: {
|
|
'files': [('download.png', testData)],
|
|
},
|
|
);
|
|
final listResponse = await server.get('/api/v1/album');
|
|
final albums = jsonDecode(listResponse.body) as List;
|
|
final albumId = albums.first['id'];
|
|
|
|
final photosResponse = await server.get('/api/v1/album/$albumId/photo');
|
|
final photos = jsonDecode(photosResponse.body) as List;
|
|
final photoId = photos.first['id'];
|
|
|
|
final response = await server.get('/api/v1/photo/$photoId/file');
|
|
expect(response.statusCode, 200);
|
|
expect(response.contentLength, greaterThan(0));
|
|
expect(response.bodyBytes, testData);
|
|
});
|
|
|
|
test(
|
|
'GET /api/v1/photo/<id>/file returns correct content type',
|
|
() async {
|
|
server = await TestServer.start(
|
|
testAlbums: {
|
|
'types': [('image.png', _createTestPng())],
|
|
},
|
|
);
|
|
final listResponse = await server.get('/api/v1/album');
|
|
final albums = jsonDecode(listResponse.body) as List;
|
|
final albumId = albums.first['id'];
|
|
|
|
final photosResponse = await server.get(
|
|
'/api/v1/album/$albumId/photo',
|
|
);
|
|
final photos = jsonDecode(photosResponse.body) as List;
|
|
final photoId = photos.first['id'];
|
|
|
|
final response = await server.get('/api/v1/photo/$photoId/file');
|
|
expect(response.headers['content-type'], contains('image/png'));
|
|
},
|
|
);
|
|
});
|
|
|
|
group('Preview endpoint', () {
|
|
test(
|
|
'GET /api/v1/photo/<id>/preview with invalid id returns 400',
|
|
() async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/photo/abc/preview');
|
|
expect(response.statusCode, 400);
|
|
expect(response.body, contains('Invalid photo id'));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'GET /api/v1/photo/<id>/preview with non-existent id returns 404',
|
|
() async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/photo/12345/preview');
|
|
expect(response.statusCode, 404);
|
|
expect(response.body, contains('Photo not found'));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'GET /api/v1/photo/<id>/preview throws when vips not installed',
|
|
() async {
|
|
server = await TestServer.start(
|
|
testAlbums: {
|
|
'preview': [('image.png', _createTestPng())],
|
|
},
|
|
);
|
|
final listResponse = await server.get('/api/v1/album');
|
|
final albums = jsonDecode(listResponse.body) as List;
|
|
final albumId = albums.first['id'];
|
|
|
|
final photosResponse = await server.get(
|
|
'/api/v1/album/$albumId/photo',
|
|
);
|
|
final photos = jsonDecode(photosResponse.body) as List;
|
|
final photoId = photos.first['id'];
|
|
|
|
// This will fail because vips CLI is not available,
|
|
// but the handler should return 500, not crash
|
|
final response = await server.get(
|
|
'/api/v1/photo/$photoId/preview?w=100&h=100',
|
|
);
|
|
expect(response.statusCode, 500);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('404 handling', () {
|
|
test('Unknown route returns 404', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/foobar');
|
|
expect(response.statusCode, 404);
|
|
});
|
|
|
|
test('Unknown nested route returns 404', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v2/unknown');
|
|
expect(response.statusCode, 404);
|
|
});
|
|
|
|
test('Unknown API endpoint returns 404', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/nonexistent');
|
|
expect(response.statusCode, 404);
|
|
});
|
|
});
|
|
|
|
group('Response format', () {
|
|
test('album endpoint returns valid JSON', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/album');
|
|
expect(response.statusCode, 200);
|
|
expect(response.headers['content-type'], contains('application/json'));
|
|
expect(() => jsonDecode(response.body), returnsNormally);
|
|
});
|
|
|
|
test('photo metadata has all required fields', () async {
|
|
server = await TestServer.start(
|
|
testAlbums: {
|
|
'format': [('test.png', _createTestPng())],
|
|
},
|
|
);
|
|
final listResponse = await server.get('/api/v1/album');
|
|
final albums = jsonDecode(listResponse.body) as List;
|
|
final albumId = albums.first['id'];
|
|
|
|
final photosResponse = await server.get('/api/v1/album/$albumId/photo');
|
|
final photos = jsonDecode(photosResponse.body) as List;
|
|
final photoId = photos.first['id'];
|
|
|
|
final response = await server.get('/api/v1/photo/$photoId');
|
|
final photo = jsonDecode(response.body) as Map;
|
|
|
|
expect(photo, containsPair('id', isA<int>()));
|
|
expect(photo, containsPair('albumId', isA<int>()));
|
|
expect(photo, containsPair('filePath', isA<String>()));
|
|
expect(photo, containsPair('fileName', isA<String>()));
|
|
expect(photo, containsPair('fileSize', isA<int>()));
|
|
expect(photo, containsPair('mimeType', isA<String>()));
|
|
expect(photo, containsPair('createdAt', isA<String>()));
|
|
});
|
|
});
|
|
|
|
group('Test isolation', () {
|
|
test('each test starts with clean data', () async {
|
|
// Start a server, add an album, stop it
|
|
server = await TestServer.start(testAlbums: {'temp_album': []});
|
|
final response = await server.get('/api/v1/album');
|
|
final albums = jsonDecode(response.body) as List;
|
|
expect(albums.length, 1);
|
|
// This test's data is isolated - the next test won't see it
|
|
});
|
|
|
|
test('previous test data is not visible', () async {
|
|
server = await TestServer.start();
|
|
final response = await server.get('/api/v1/album');
|
|
final albums = jsonDecode(response.body) as List;
|
|
// Should be empty because previous test's data was cleaned up
|
|
expect(albums, isEmpty);
|
|
});
|
|
});
|
|
});
|
|
}
|