import 'dart:convert'; import 'dart:io'; 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 _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 start({ Map)>>? 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 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 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/ 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/ 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/ 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/ 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/ 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//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//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//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/ 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/ 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/ 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())); }); test('GET /api/v1/photo//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//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//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//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//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//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//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())); expect(photo, containsPair('albumId', isA())); expect(photo, containsPair('filePath', isA())); expect(photo, containsPair('fileName', isA())); expect(photo, containsPair('fileSize', isA())); expect(photo, containsPair('mimeType', isA())); expect(photo, containsPair('createdAt', isA())); }); }); 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); }); }); }); }