From 120278aa85acf9c498724017907110198fd385c2 Mon Sep 17 00:00:00 2001 From: lzw-723 Date: Sat, 4 Apr 2026 13:36:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/caches/deviceStreaming.xml | 1574 +++++++++++++++++ bin/router.dart | 103 +- test/integration/server_test.dart | 505 ++++++ test/server_test.dart | 192 -- test/unit/config/app_config_test.dart | 72 + test/unit/domain/entities/album_test.dart | 64 + test/unit/domain/entities/photo_test.dart | 127 ++ .../repositories/album_repository_test.dart | 138 ++ .../repositories/photo_repository_test.dart | 613 +++++++ .../middleware/exception_handler_test.dart | 133 ++ test/unit/util/vips_test.dart | 190 ++ 11 files changed, 3488 insertions(+), 223 deletions(-) create mode 100644 .idea/caches/deviceStreaming.xml create mode 100644 test/integration/server_test.dart delete mode 100644 test/server_test.dart create mode 100644 test/unit/config/app_config_test.dart create mode 100644 test/unit/domain/entities/album_test.dart create mode 100644 test/unit/domain/entities/photo_test.dart create mode 100644 test/unit/domain/repositories/album_repository_test.dart create mode 100644 test/unit/domain/repositories/photo_repository_test.dart create mode 100644 test/unit/middleware/exception_handler_test.dart create mode 100644 test/unit/util/vips_test.dart diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml new file mode 100644 index 0000000..c31fe54 --- /dev/null +++ b/.idea/caches/deviceStreaming.xml @@ -0,0 +1,1574 @@ + + + + + + \ No newline at end of file diff --git a/bin/router.dart b/bin/router.dart index bc1cbb0..aa02449 100644 --- a/bin/router.dart +++ b/bin/router.dart @@ -22,29 +22,63 @@ Response jsonResponse(dynamic data) { ); } -/// 创建 API v1 版本的路由 -Router createApiV1Router() { - final router = Router() - ..get('/', _rootHandler) - ..get("/album", _listAlbumHandler) - ..get("/album/", _getAlbumHandler) - ..get("/album//photo", _getPhotosOfAlbumHandler) - ..get('/photo/', _getPhotoHandler) - ..get('/photo//file', _getPhotoFileHandler) - ..get('/photo//preview', _getPhotoPreviewHandler) - ..get('/echo/', _echoHandler); +/// 路由依赖注入容器 +/// +/// 允许在测试中使用 mock 或临时目录,而不是全局单例。 +class AppRouter { + final AlbumRepository albumRepository; + final PhotoRepository photoRepository; + final Vips vips; - return router; + AppRouter({ + AlbumRepository? albumRepository, + PhotoRepository? photoRepository, + Vips? vips, + }) : albumRepository = + albumRepository ?? AlbumRepository(basePath: _config.dataDir), + photoRepository = photoRepository ?? PhotoRepository(_config.dataDir), + vips = vips ?? Vips(cacheDir: _config.cacheDir); + + /// 创建 API v1 版本的路由 + Router createApiV1Router() { + final router = Router() + ..get('/', _rootHandler) + ..get('/album', (req) => _listAlbumHandler(req, albumRepository)) + ..get('/album/', (req) => _getAlbumHandler(req, albumRepository)) + ..get( + '/album//photo', + (req) => _getPhotosOfAlbumHandler(req, photoRepository), + ) + ..get('/photo/', (req) => _getPhotoHandler(req, photoRepository)) + ..get( + '/photo//file', + (req) => _getPhotoFileHandler(req, photoRepository), + ) + ..get( + '/photo//preview', + (req) => _getPhotoPreviewHandler(req, photoRepository, vips), + ) + ..get('/echo/', _echoHandler); + + return router; + } + + /// 创建主路由器,将 API v1 挂载到 /api/v1 路径下 + Router createRouter() { + final router = Router(); + router.mount('/api/v1', createApiV1Router().call); + return router; + } } -/// 创建主路由器,将 API v1 挂载到 /api/v1 路径下 +/// 创建 API v1 版本的路由(使用全局单例,保持向后兼容) +Router createApiV1Router() { + return AppRouter().createApiV1Router(); +} + +/// 创建主路由器,将 API v1 挂载到 /api/v1 路径下(使用全局单例,保持向后兼容) Router createRouter() { - final router = Router(); - - // 将 API v1 路由挂载到 /api/v1 路径下 - router.mount('/api/v1', createApiV1Router().call); - - return router; + return AppRouter().createRouter(); } Response _rootHandler(Request req) { @@ -56,8 +90,8 @@ Response _echoHandler(Request request) { return Response.ok('$message\n'); } -Future _listAlbumHandler(Request req) async { - final albums = await albumRepository.getAllAlbums(); +Future _listAlbumHandler(Request req, AlbumRepository repo) async { + final albums = await repo.getAllAlbums(); final albumList = albums .map( (a) => { @@ -71,7 +105,7 @@ Future _listAlbumHandler(Request req) async { return jsonResponse(albumList); } -Future _getAlbumHandler(Request req) async { +Future _getAlbumHandler(Request req, AlbumRepository repo) async { final idParam = req.params['id']; if (idParam == null) { return Response.badRequest(body: 'Missing album id'); @@ -80,14 +114,14 @@ Future _getAlbumHandler(Request req) async { if (id == null) { return Response.badRequest(body: 'Invalid album id: $idParam'); } - final album = await albumRepository.getAlbumById(id); + final album = await repo.getAlbumById(id); if (album == null) { return Response.notFound('Album not found'); } return jsonResponse({'id': album.id, 'name': album.name}); } -Future _getPhotoHandler(Request req) async { +Future _getPhotoHandler(Request req, PhotoRepository repo) async { final idParam = req.params['id']; if (idParam == null) { return Response.badRequest(body: 'Missing photo id'); @@ -97,14 +131,14 @@ Future _getPhotoHandler(Request req) async { return Response.badRequest(body: 'Invalid photo id: $idParam'); } - final photo = await photoRepository.getPhotoById(id); + final photo = await repo.getPhotoById(id); if (photo == null) { return Response.notFound('Photo not found'); } return jsonResponse(photo); } -Future _getPhotoFileHandler(Request req) async { +Future _getPhotoFileHandler(Request req, PhotoRepository repo) async { final idParam = req.params['id']; if (idParam == null) { return Response.badRequest(body: 'Missing photo id'); @@ -114,7 +148,7 @@ Future _getPhotoFileHandler(Request req) async { return Response.badRequest(body: 'Invalid photo id: $idParam'); } - final photo = await photoRepository.getPhotoById(id); + final photo = await repo.getPhotoById(id); if (photo == null) { return Response.notFound('Photo not found'); } @@ -153,7 +187,10 @@ Future _getPhotoFileHandler(Request req) async { ); } -Future _getPhotosOfAlbumHandler(Request req) async { +Future _getPhotosOfAlbumHandler( + Request req, + PhotoRepository repo, +) async { final idParam = req.params['id']; if (idParam == null) { return Response.badRequest(body: 'Missing album id'); @@ -162,11 +199,15 @@ Future _getPhotosOfAlbumHandler(Request req) async { if (albumId == null) { return Response.badRequest(body: 'Invalid album id: $idParam'); } - final photos = await photoRepository.getPhotosByAlbumId(albumId); + final photos = await repo.getPhotosByAlbumId(albumId); return jsonResponse(photos); } -Future _getPhotoPreviewHandler(Request req) async { +Future _getPhotoPreviewHandler( + Request req, + PhotoRepository repo, + Vips vips, +) async { final idParam = req.params['id']; if (idParam == null) { return Response.badRequest(body: 'Missing photo id'); @@ -176,7 +217,7 @@ Future _getPhotoPreviewHandler(Request req) async { return Response.badRequest(body: 'Invalid photo id: $idParam'); } - final photo = await photoRepository.getPhotoById(id); + final photo = await repo.getPhotoById(id); if (photo == null) { return Response.notFound('Photo not found'); } diff --git a/test/integration/server_test.dart b/test/integration/server_test.dart new file mode 100644 index 0000000..c2458ce --- /dev/null +++ b/test/integration/server_test.dart @@ -0,0 +1,505 @@ +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); + }); + }); + }); +} diff --git a/test/server_test.dart b/test/server_test.dart deleted file mode 100644 index 7c8d232..0000000 --- a/test/server_test.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'dart:io'; -import 'dart:convert'; - -import 'package:http/http.dart' as http; -import 'package:test/test.dart'; - -void main() { - final port = '8080'; - final host = 'http://localhost:$port'; - late Process p; - - setUp(() async { - p = await Process.start( - 'dart', - ['run', 'bin/server.dart'], - environment: {'PORT': port}, - ); - // Wait for server to start (give it some time to initialize) - await Future.delayed(const Duration(seconds: 3)); - }); - - tearDown(() async { - p.kill(); - // Wait for process to exit - try { - await p.exitCode.timeout(const Duration(seconds: 5), onTimeout: () => 0); - } catch (_) {} - }); - - group('Root endpoint', () { - test('GET /api/v1/ returns Hello World', () async { - final response = await http.get(Uri.parse('$host/api/v1/')); - expect(response.statusCode, 200); - expect(response.body, 'Hello, World!\n'); - }); - }); - - group('Echo endpoint', () { - test('GET /api/v1/echo/ returns the message', () async { - final response = await http.get(Uri.parse('$host/api/v1/echo/hello')); - expect(response.statusCode, 200); - expect(response.body, 'hello\n'); - }); - - test('GET /api/v1/echo/ with special characters', () async { - final response = await http.get( - Uri.parse('$host/api/v1/echo/test%20message'), - ); - expect(response.statusCode, 200); - expect(response.body, contains('test')); - }); - }); - - group('Album endpoints', () { - test('GET /api/v1/album returns list of albums', () async { - final response = await http.get(Uri.parse('$host/api/v1/album')); - expect(response.statusCode, 200); - expect(response.headers['content-type'], contains('application/json')); - final albums = jsonDecode(response.body) as List; - expect(albums, isA()); - if (albums.isNotEmpty) { - expect(albums.first, containsPair('id', isA())); - expect(albums.first, containsPair('name', isA())); - } - }); - - test('GET /api/v1/album/ with invalid id returns 400', () async { - final response = await http.get( - Uri.parse('$host/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 { - final response = await http.get(Uri.parse('$host/api/v1/album/999999')); - expect(response.statusCode, 404); - expect(response.body, contains('Album not found')); - }); - - test('GET /api/v1/album//photo with invalid id returns 400', () async { - final response = await http.get( - Uri.parse('$host/api/v1/album/invalid_id/photo'), - ); - expect(response.statusCode, 400); - expect(response.body, contains('Invalid album id')); - }); - - test('GET /api/v1/album//photo with valid id returns photos', () async { - // First get the list to find a valid album id - final listResponse = await http.get(Uri.parse('$host/api/v1/album')); - expect(listResponse.statusCode, 200); - final albums = jsonDecode(listResponse.body) as List; - - // Skip test if no albums exist - if (albums.isEmpty) { - return; - } - - final album = albums.first as Map; - final albumId = album['id']; - - final response = await http.get( - Uri.parse('$host/api/v1/album/$albumId/photo'), - ); - expect(response.statusCode, 200); - expect(response.headers['content-type'], contains('application/json')); - final photos = jsonDecode(response.body) as List; - expect(photos, isA()); - }); - }); - - group('Photo endpoints', () { - test('GET /api/v1/photo/ with invalid id returns 400', () async { - final response = await http.get(Uri.parse('$host/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 { - // Use a hash that won't match any file - final response = await http.get(Uri.parse('$host/api/v1/photo/12345')); - expect(response.statusCode, 404); - expect(response.body, contains('Photo not found')); - }); - - test('GET /api/v1/photo//file with invalid id returns 400', () async { - final response = await http.get(Uri.parse('$host/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 { - // Use a hash that won't match any file - final response = await http.get( - Uri.parse('$host/api/v1/photo/12345/file'), - ); - expect(response.statusCode, 404); - expect(response.body, contains('Photo not found')); - }, - ); - - test('GET /api/v1/photo//file with valid id returns file', () async { - // First get a valid photo id from an album - final albumResponse = await http.get(Uri.parse('$host/api/v1/album')); - expect(albumResponse.statusCode, 200); - final albums = jsonDecode(albumResponse.body) as List; - - // Skip test if no albums exist - if (albums.isEmpty) { - return; - } - - final album = albums.first as Map; - final albumId = album['id']; - - final photosResponse = await http.get( - Uri.parse('$host/api/v1/album/$albumId/photo'), - ); - expect(photosResponse.statusCode, 200); - final photos = jsonDecode(photosResponse.body) as List; - - // Skip test if no photos exist - if (photos.isEmpty) { - return; - } - - final photo = photos.first as Map; - final photoId = photo['id']; - - final response = await http.get( - Uri.parse('$host/api/v1/photo/$photoId/file'), - ); - expect(response.statusCode, 200); - expect(response.contentLength, greaterThan(0)); - }); - }); - - group('404 handling', () { - test('Unknown route returns 404', () async { - final response = await http.get(Uri.parse('$host/foobar')); - expect(response.statusCode, 404); - }); - - test('Unknown nested route returns 404', () async { - final response = await http.get(Uri.parse('$host/api/v2/unknown')); - expect(response.statusCode, 404); - }); - }); -} diff --git a/test/unit/config/app_config_test.dart b/test/unit/config/app_config_test.dart new file mode 100644 index 0000000..0a88697 --- /dev/null +++ b/test/unit/config/app_config_test.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../../../bin/config/app_config.dart'; + +void main() { + group('AppConfig', () { + test('should return non-empty dataDir and cacheDir', () { + final config = AppConfig(); + + expect(config.dataDir, isA()); + expect(config.dataDir, isNotEmpty); + expect(config.cacheDir, isA()); + expect(config.cacheDir, isNotEmpty); + }); + + test('previewCacheDir should end with cacheDir/preview', () { + final config = AppConfig(); + + expect(config.previewCacheDir, endsWith('preview')); + expect(config.previewCacheDir, contains(config.cacheDir)); + }); + + test( + 'ensureDirectoriesExist should create data and cache directories', + () async { + final config = AppConfig(); + + await config.ensureDirectoriesExist(); + + expect(Directory(config.dataDir).existsSync(), isTrue); + expect(Directory(config.previewCacheDir).existsSync(), isTrue); + }, + ); + + test('ensureDirectoriesExist should be idempotent', () async { + final config = AppConfig(); + + // Call twice + await config.ensureDirectoriesExist(); + await config.ensureDirectoriesExist(); + + expect(Directory(config.dataDir).existsSync(), isTrue); + expect(Directory(config.previewCacheDir).existsSync(), isTrue); + }); + + test('toString should return readable description', () { + final config = AppConfig(); + final str = config.toString(); + + expect(str, contains('AppConfig')); + expect(str, contains('dataDir')); + expect(str, contains('cacheDir')); + }); + + test('should be a singleton', () { + final config1 = AppConfig(); + final config2 = AppConfig(); + + expect(identical(config1, config2), isTrue); + }); + + test('toString should include actual path values', () { + final config = AppConfig(); + final str = config.toString(); + + expect(str, contains(config.dataDir)); + expect(str, contains(config.cacheDir)); + }); + }); +} diff --git a/test/unit/domain/entities/album_test.dart b/test/unit/domain/entities/album_test.dart new file mode 100644 index 0000000..28ffaab --- /dev/null +++ b/test/unit/domain/entities/album_test.dart @@ -0,0 +1,64 @@ +import 'package:test/test.dart'; + +import '../../../../bin/domain/entities/album.dart'; + +void main() { + group('Album', () { + test('should create an Album with required fields', () { + final now = DateTime.now(); + final album = Album( + id: 1, + name: 'Vacation', + createdAt: now, + updatedAt: now, + ); + + expect(album.id, 1); + expect(album.name, 'Vacation'); + expect(album.createdAt, now); + expect(album.updatedAt, now); + }); + + test('toJson should return correct map', () { + final now = DateTime(2024, 1, 1, 12, 0, 0); + final album = Album( + id: 42, + name: 'Birthday', + createdAt: now, + updatedAt: now, + ); + + final json = album.toJson(); + + expect(json, isA>()); + expect(json['id'], 42); + expect(json['name'], 'Birthday'); + expect(json['createdAt'], now.toIso8601String()); + expect(json['updatedAt'], now.toIso8601String()); + }); + + test('toJson should serialize dates in ISO8601 format', () { + final createdAt = DateTime(2023, 6, 15); + final updatedAt = DateTime(2023, 12, 25); + final album = Album( + id: 1, + name: 'Test', + createdAt: createdAt, + updatedAt: updatedAt, + ); + + final json = album.toJson(); + + expect(json['createdAt'], '2023-06-15T00:00:00.000'); + expect(json['updatedAt'], '2023-12-25T00:00:00.000'); + }); + + test('should handle empty name', () { + final now = DateTime.now(); + final album = Album(id: 0, name: '', createdAt: now, updatedAt: now); + + expect(album.name, ''); + expect(album.toJson()['name'], ''); + }); + }); +} diff --git a/test/unit/domain/entities/photo_test.dart b/test/unit/domain/entities/photo_test.dart new file mode 100644 index 0000000..9402af5 --- /dev/null +++ b/test/unit/domain/entities/photo_test.dart @@ -0,0 +1,127 @@ +import 'package:test/test.dart'; + +import '../../../../bin/domain/entities/photo.dart'; + +void main() { + group('Photo', () { + test('should create a Photo with required fields', () { + final now = DateTime.now(); + final photo = Photo( + id: 1, + albumId: 10, + filePath: '/data/vacation/photo.jpg', + fileName: 'photo.jpg', + fileSize: 1024000, + mimeType: 'image/jpeg', + createdAt: now, + ); + + expect(photo.id, 1); + expect(photo.albumId, 10); + expect(photo.filePath, '/data/vacation/photo.jpg'); + expect(photo.fileName, 'photo.jpg'); + expect(photo.fileSize, 1024000); + expect(photo.mimeType, 'image/jpeg'); + expect(photo.width, isNull); + expect(photo.height, isNull); + expect(photo.createdAt, now); + }); + + test('should create a Photo with optional dimensions', () { + final now = DateTime.now(); + final photo = Photo( + id: 2, + albumId: 10, + filePath: '/data/vacation/photo.png', + fileName: 'photo.png', + fileSize: 2048000, + mimeType: 'image/png', + width: 1920, + height: 1080, + createdAt: now, + ); + + expect(photo.width, 1920); + expect(photo.height, 1080); + }); + + test('toJson should return correct map', () { + final now = DateTime(2024, 6, 15, 10, 30, 0); + final photo = Photo( + id: 42, + albumId: 5, + filePath: '/data/test/image.webp', + fileName: 'image.webp', + fileSize: 500000, + mimeType: 'image/webp', + width: 800, + height: 600, + createdAt: now, + ); + + final json = photo.toJson(); + + expect(json, isA>()); + expect(json['id'], 42); + expect(json['albumId'], 5); + expect(json['filePath'], '/data/test/image.webp'); + expect(json['fileName'], 'image.webp'); + expect(json['fileSize'], 500000); + expect(json['mimeType'], 'image/webp'); + expect(json['width'], 800); + expect(json['height'], 600); + expect(json['createdAt'], now.toIso8601String()); + }); + + test('toJson should include null dimensions when not set', () { + final now = DateTime.now(); + final photo = Photo( + id: 1, + albumId: 1, + filePath: '/data/video.mp4', + fileName: 'video.mp4', + fileSize: 10000, + mimeType: 'video/mp4', + createdAt: now, + ); + + final json = photo.toJson(); + + expect(json['width'], isNull); + expect(json['height'], isNull); + }); + + test('toJson should serialize dates in ISO8601 format', () { + final createdAt = DateTime(2024, 1, 1); + final photo = Photo( + id: 1, + albumId: 1, + filePath: '/data/test.jpg', + fileName: 'test.jpg', + fileSize: 1000, + mimeType: 'image/jpeg', + createdAt: createdAt, + ); + + final json = photo.toJson(); + + expect(json['createdAt'], '2024-01-01T00:00:00.000'); + }); + + test('should support video mime types', () { + final now = DateTime.now(); + final photo = Photo( + id: 3, + albumId: 1, + filePath: '/data/clip.mp4', + fileName: 'clip.mp4', + fileSize: 5000000, + mimeType: 'video/mp4', + createdAt: now, + ); + + expect(photo.mimeType, 'video/mp4'); + expect(photo.toJson()['mimeType'], 'video/mp4'); + }); + }); +} diff --git a/test/unit/domain/repositories/album_repository_test.dart b/test/unit/domain/repositories/album_repository_test.dart new file mode 100644 index 0000000..fe4aba2 --- /dev/null +++ b/test/unit/domain/repositories/album_repository_test.dart @@ -0,0 +1,138 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../../../../bin/domain/entities/album.dart'; +import '../../../../bin/domain/repositories/album_repository.dart'; + +void main() { + late Directory tempDir; + late AlbumRepository repository; + + setUp(() async { + // Create a temporary directory for each test + tempDir = await Directory.systemTemp.createTemp('album_repo_test_'); + repository = AlbumRepository(basePath: tempDir.path); + }); + + tearDown(() async { + // Clean up the temporary directory + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('getAllAlbums', () { + test('should return empty list when directory does not exist', () async { + final repo = AlbumRepository( + basePath: '/nonexistent/path/that/does/not/exist', + ); + final albums = await repo.getAllAlbums(); + expect(albums, isEmpty); + }); + + test('should return empty list when directory is empty', () async { + final albums = await repository.getAllAlbums(); + expect(albums, isEmpty); + }); + + test('should return albums for each subdirectory', () async { + // Create test directories + await Directory(p.join(tempDir.path, 'vacation')).create(); + await Directory(p.join(tempDir.path, 'birthday')).create(); + await Directory(p.join(tempDir.path, 'family')).create(); + + final albums = await repository.getAllAlbums(); + + expect(albums.length, 3); + expect( + albums.map((a) => a.name), + containsAll(['vacation', 'birthday', 'family']), + ); + }); + + test('should not include files as albums', () async { + // Create a directory and a file + await Directory(p.join(tempDir.path, 'vacation')).create(); + await File(p.join(tempDir.path, 'somefile.txt')).writeAsString('test'); + + final albums = await repository.getAllAlbums(); + + expect(albums.length, 1); + expect(albums.first.name, 'vacation'); + }); + + test('should generate consistent id from directory name', () async { + await Directory(p.join(tempDir.path, 'test_album')).create(); + + final albums1 = await repository.getAllAlbums(); + final albums2 = await repository.getAllAlbums(); + + expect(albums1.first.id, albums2.first.id); + expect(albums1.first.id, 'test_album'.hashCode); + }); + + test('should set createdAt and updatedAt from directory stat', () async { + await Directory(p.join(tempDir.path, 'recent')).create(); + + final albums = await repository.getAllAlbums(); + + expect(albums.first.createdAt, isA()); + expect(albums.first.updatedAt, isA()); + }); + + test('should return Album objects', () async { + await Directory(p.join(tempDir.path, 'my_album')).create(); + + final albums = await repository.getAllAlbums(); + + expect(albums.first, isA()); + expect(albums.first.id, isA()); + expect(albums.first.name, 'my_album'); + }); + }); + + group('getAlbumById', () { + test('should return null when directory does not exist', () async { + final repo = AlbumRepository(basePath: '/nonexistent/path'); + final album = await repo.getAlbumById(123); + expect(album, isNull); + }); + + test('should return null when no albums match the id', () async { + await Directory(p.join(tempDir.path, 'album1')).create(); + + final album = await repository.getAlbumById(999999); + + expect(album, isNull); + }); + + test('should return album when id matches', () async { + await Directory(p.join(tempDir.path, 'target_album')).create(); + + final allAlbums = await repository.getAllAlbums(); + final targetId = allAlbums.first.id; + + final album = await repository.getAlbumById(targetId); + + expect(album, isNotNull); + expect(album!.name, 'target_album'); + expect(album.id, targetId); + }); + + test('should return correct album among multiple albums', () async { + await Directory(p.join(tempDir.path, 'alpha')).create(); + await Directory(p.join(tempDir.path, 'beta')).create(); + await Directory(p.join(tempDir.path, 'gamma')).create(); + + final allAlbums = await repository.getAllAlbums(); + final betaAlbum = allAlbums.firstWhere((a) => a.name == 'beta'); + + final album = await repository.getAlbumById(betaAlbum.id); + + expect(album, isNotNull); + expect(album!.name, 'beta'); + }); + }); +} diff --git a/test/unit/domain/repositories/photo_repository_test.dart b/test/unit/domain/repositories/photo_repository_test.dart new file mode 100644 index 0000000..584e783 --- /dev/null +++ b/test/unit/domain/repositories/photo_repository_test.dart @@ -0,0 +1,613 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../../../../bin/domain/repositories/photo_repository.dart'; + +void main() { + late Directory tempDir; + late PhotoRepository repository; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('photo_repo_test_'); + repository = PhotoRepository(tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + // ===== Helper methods to create test image files ===== + + /// Create a minimal valid PNG file (1x1 pixel, IHDR at known position) + File _createTestPng( + Directory dir, + String name, { + int width = 100, + int height = 200, + }) { + final buffer = BytesBuilder(); + + // PNG signature (8 bytes) + 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 (4 bytes) - 13 bytes of data + 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 (4 bytes, big-endian) + buffer.addByte((width >> 24) & 0xFF); + buffer.addByte((width >> 16) & 0xFF); + buffer.addByte((width >> 8) & 0xFF); + buffer.addByte(width & 0xFF); + + // Height (4 bytes, 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); + + final file = File(p.join(dir.path, name)); + file.writeAsBytesSync(buffer.toBytes()); + return file; + } + + /// Create a minimal valid GIF file + File _createTestGif( + Directory dir, + String name, { + int width = 150, + int height = 250, + }) { + final buffer = BytesBuilder(); + + // GIF89a header + buffer.addByte(0x47); + buffer.addByte(0x49); + buffer.addByte(0x46); + buffer.addByte(0x38); + buffer.addByte(0x39); + buffer.addByte(0x61); + + // Width (little-endian) + buffer.addByte(width & 0xFF); + buffer.addByte((width >> 8) & 0xFF); + + // Height (little-endian) + buffer.addByte(height & 0xFF); + buffer.addByte((height >> 8) & 0xFF); + + buffer.addByte(0x00); + buffer.addByte(0x00); + buffer.addByte(0x00); + buffer.addByte(0x3B); + + final file = File(p.join(dir.path, name)); + file.writeAsBytesSync(buffer.toBytes()); + return file; + } + + /// Create a minimal valid BMP file + File _createTestBmp( + Directory dir, + String name, { + int width = 300, + int height = 400, + }) { + final buffer = BytesBuilder(); + + buffer.addByte(0x42); + buffer.addByte(0x4D); + + final fileSize = 54 + width * height * 3; + buffer.addByte(fileSize & 0xFF); + buffer.addByte((fileSize >> 8) & 0xFF); + buffer.addByte((fileSize >> 16) & 0xFF); + buffer.addByte((fileSize >> 24) & 0xFF); + + // Reserved + buffer.addByte(0x00); + buffer.addByte(0x00); + buffer.addByte(0x00); + buffer.addByte(0x00); + + // Offset to pixel data + buffer.addByte(0x36); + buffer.addByte(0x00); + buffer.addByte(0x00); + buffer.addByte(0x00); + + // DIB header + buffer.addByte(0x28); + buffer.addByte(0x00); + buffer.addByte(0x00); + buffer.addByte(0x00); + + // Width (little-endian) + buffer.addByte(width & 0xFF); + buffer.addByte((width >> 8) & 0xFF); + buffer.addByte((width >> 16) & 0xFF); + buffer.addByte((width >> 24) & 0xFF); + + // Height (little-endian) + buffer.addByte(height & 0xFF); + buffer.addByte((height >> 8) & 0xFF); + buffer.addByte((height >> 16) & 0xFF); + buffer.addByte((height >> 24) & 0xFF); + + // Planes, bits per pixel + buffer.addByte(0x01); + buffer.addByte(0x00); + buffer.addByte(0x18); + buffer.addByte(0x00); + + // Compression (0) + buffer.addByte(0x00); + buffer.addByte(0x00); + buffer.addByte(0x00); + buffer.addByte(0x00); + + // Padding + for (var i = 0; i < 24; i++) { + buffer.addByte(0x00); + } + + final file = File(p.join(dir.path, name)); + file.writeAsBytesSync(buffer.toBytes()); + return file; + } + + /// Create a minimal valid WebP VP8 (lossy) file + File _createTestWebpVp8( + Directory dir, + String name, { + int width = 120, + int height = 80, + }) { + final buffer = BytesBuilder(); + + // RIFF + buffer.addByte(0x52); + buffer.addByte(0x49); + buffer.addByte(0x46); + buffer.addByte(0x46); + + // File size (placeholder) + buffer.addByte(0x20); + buffer.addByte(0x00); + buffer.addByte(0x00); + buffer.addByte(0x00); + + // WEBP + buffer.addByte(0x57); + buffer.addByte(0x45); + buffer.addByte(0x42); + buffer.addByte(0x50); + + // VP8 + buffer.addByte(0x56); + buffer.addByte(0x50); + buffer.addByte(0x38); + buffer.addByte(0x20); + + // Chunk size + buffer.addByte(0x10); + buffer.addByte(0x00); + buffer.addByte(0x00); + buffer.addByte(0x00); + + // Frame tag bytes (positions 20-21) + buffer.addByte(0x00); + buffer.addByte(0x00); + + // Key check: (data[23] << 8) | data[22] == 0x9D01 (positions 22-23) + buffer.addByte(0x01); + buffer.addByte(0x9D); + + // Padding before width/height (positions 24-25) + buffer.addByte(0x00); + buffer.addByte(0x00); + + // Width (little-endian) + buffer.addByte(width & 0xFF); + buffer.addByte((width >> 8) & 0xFF); + + // Height (little-endian) + buffer.addByte(height & 0xFF); + buffer.addByte((height >> 8) & 0xFF); + + final file = File(p.join(dir.path, name)); + file.writeAsBytesSync(buffer.toBytes()); + return file; + } + + // ===== Tests ===== + + group('getPhotosByAlbumId', () { + test( + 'should return empty list when base directory does not exist', + () async { + final repo = PhotoRepository('/nonexistent/path'); + final photos = await repo.getPhotosByAlbumId(123); + expect(photos, isEmpty); + }, + ); + + test( + 'should return empty list when album directory does not exist', + () async { + final photos = await repository.getPhotosByAlbumId( + 'nonexistent'.hashCode, + ); + expect(photos, isEmpty); + }, + ); + + test('should return photos for a valid album directory', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'my_album'), + ).create(); + _createTestPng(albumDir, 'photo1.png', width: 100, height: 200); + _createTestGif(albumDir, 'photo2.gif', width: 150, height: 250); + + final albumId = 'my_album'.hashCode; + final photos = await repository.getPhotosByAlbumId(albumId); + + expect(photos.length, 2); + expect( + photos.map((p) => p.fileName), + containsAll(['photo1.png', 'photo2.gif']), + ); + }); + + test('should not include non-image files', () async { + final albumDir = await Directory(p.join(tempDir.path, 'mixed')).create(); + _createTestPng(albumDir, 'image.png'); + File(p.join(albumDir.path, 'document.txt')).writeAsString('not an image'); + File(p.join(albumDir.path, 'readme.md')).writeAsString('# Readme'); + + final albumId = 'mixed'.hashCode; + final photos = await repository.getPhotosByAlbumId(albumId); + + expect(photos.length, 1); + expect(photos.first.fileName, 'image.png'); + }); + + test('should parse PNG dimensions correctly', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'png_test'), + ).create(); + _createTestPng(albumDir, 'test.png', width: 640, height: 480); + + final albumId = 'png_test'.hashCode; + final photos = await repository.getPhotosByAlbumId(albumId); + + expect(photos.length, 1); + expect(photos.first.width, 640); + expect(photos.first.height, 480); + expect(photos.first.mimeType, 'image/png'); + }); + + test('should parse GIF dimensions correctly', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'gif_test'), + ).create(); + _createTestGif(albumDir, 'test.gif', width: 320, height: 240); + + final albumId = 'gif_test'.hashCode; + final photos = await repository.getPhotosByAlbumId(albumId); + + expect(photos.length, 1); + expect(photos.first.width, 320); + expect(photos.first.height, 240); + expect(photos.first.mimeType, 'image/gif'); + }); + + test('should parse BMP dimensions correctly', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'bmp_test'), + ).create(); + _createTestBmp(albumDir, 'test.bmp', width: 800, height: 600); + + final albumId = 'bmp_test'.hashCode; + final photos = await repository.getPhotosByAlbumId(albumId); + + expect(photos.length, 1); + expect(photos.first.width, 800); + expect(photos.first.height, 600); + expect(photos.first.mimeType, 'image/bmp'); + }); + + test('should handle WebP VP8 dimensions', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'webp_test'), + ).create(); + _createTestWebpVp8(albumDir, 'test.webp', width: 1920, height: 1080); + + final albumId = 'webp_test'.hashCode; + final photos = await repository.getPhotosByAlbumId(albumId); + + expect(photos.length, 1); + expect(photos.first.width, 1920); + expect(photos.first.height, 1080); + expect(photos.first.mimeType, 'image/webp'); + }); + + test('should return null dimensions for video files', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'video_test'), + ).create(); + File( + p.join(albumDir.path, 'clip.mp4'), + ).writeAsBytesSync(List.filled(100, 0)); + + final albumId = 'video_test'.hashCode; + final photos = await repository.getPhotosByAlbumId(albumId); + + expect(photos.length, 1); + expect(photos.first.width, isNull); + expect(photos.first.height, isNull); + expect(photos.first.mimeType, 'video/mp4'); + }); + + test('should include video files in results', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'video_album'), + ).create(); + File( + p.join(albumDir.path, 'movie.mp4'), + ).writeAsBytesSync(List.filled(100, 0)); + + final albumId = 'video_album'.hashCode; + final photos = await repository.getPhotosByAlbumId(albumId); + + expect(photos.length, 1); + expect(photos.first.fileName, 'movie.mp4'); + }); + + test('should set correct photo metadata', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'meta_test'), + ).create(); + _createTestPng(albumDir, 'test.png', width: 100, height: 100); + + final albumId = 'meta_test'.hashCode; + final photos = await repository.getPhotosByAlbumId(albumId); + + expect(photos.first.albumId, albumId); + expect(photos.first.fileName, 'test.png'); + expect(photos.first.fileSize, greaterThan(0)); + expect(photos.first.createdAt, isA()); + }); + }); + + group('getPhotoById', () { + test('should return null when base directory does not exist', () async { + final repo = PhotoRepository('/nonexistent/path'); + final photo = await repo.getPhotoById(123); + expect(photo, isNull); + }); + + test('should return null when photo does not exist', () async { + final photo = await repository.getPhotoById(999999); + expect(photo, isNull); + }); + + test('should return photo when id matches', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'find_album'), + ).create(); + _createTestPng(albumDir, 'unique.png', width: 50, height: 50); + + final allPhotos = await repository.getPhotosByAlbumId( + 'find_album'.hashCode, + ); + final targetId = allPhotos.first.id; + + final photo = await repository.getPhotoById(targetId); + + expect(photo, isNotNull); + expect(photo!.fileName, 'unique.png'); + expect(photo.width, 50); + expect(photo.height, 50); + }); + + test('should parse dimensions when retrieving by id', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'dims_album'), + ).create(); + _createTestBmp(albumDir, 'dims.bmp', width: 1024, height: 768); + + final allPhotos = await repository.getPhotosByAlbumId( + 'dims_album'.hashCode, + ); + final targetId = allPhotos.first.id; + + final photo = await repository.getPhotoById(targetId); + + expect(photo, isNotNull); + expect(photo!.width, 1024); + expect(photo.height, 768); + }); + }); + + group('mime type detection', () { + test('should detect jpg/jpeg as image/jpeg', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'mime_jpg'), + ).create(); + File(p.join(albumDir.path, 'test.jpg')).writeAsBytesSync([0xFF, 0xD8]); + File(p.join(albumDir.path, 'test2.jpeg')).writeAsBytesSync([0xFF, 0xD8]); + + final photos = await repository.getPhotosByAlbumId('mime_jpg'.hashCode); + expect(photos.length, 2); + expect(photos.every((p) => p.mimeType == 'image/jpeg'), isTrue); + }); + + test('should detect png as image/png', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'mime_png'), + ).create(); + File( + p.join(albumDir.path, 'test.png'), + ).writeAsBytesSync(List.filled(100, 0)); + + final photos = await repository.getPhotosByAlbumId('mime_png'.hashCode); + expect(photos.first.mimeType, 'image/png'); + }); + + test('should detect webp as image/webp', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'mime_webp'), + ).create(); + File( + p.join(albumDir.path, 'test.webp'), + ).writeAsBytesSync(List.filled(100, 0)); + + final photos = await repository.getPhotosByAlbumId('mime_webp'.hashCode); + expect(photos.first.mimeType, 'image/webp'); + }); + + test('should detect mp4 as video/mp4', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'mime_mp4'), + ).create(); + File( + p.join(albumDir.path, 'test.mp4'), + ).writeAsBytesSync(List.filled(100, 0)); + + final photos = await repository.getPhotosByAlbumId('mime_mp4'.hashCode); + expect(photos.first.mimeType, 'video/mp4'); + }); + + test('should detect avi as video/avi', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'mime_avi'), + ).create(); + File( + p.join(albumDir.path, 'test.avi'), + ).writeAsBytesSync(List.filled(100, 0)); + + final photos = await repository.getPhotosByAlbumId('mime_avi'.hashCode); + expect(photos.first.mimeType, 'video/avi'); + }); + + test('should detect mov as video/quicktime', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'mime_mov'), + ).create(); + File( + p.join(albumDir.path, 'test.mov'), + ).writeAsBytesSync(List.filled(100, 0)); + + final photos = await repository.getPhotosByAlbumId('mime_mov'.hashCode); + expect(photos.first.mimeType, 'video/quicktime'); + }); + + test('should detect mkv as video/x-matroska', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'mime_mkv'), + ).create(); + File( + p.join(albumDir.path, 'test.mkv'), + ).writeAsBytesSync(List.filled(100, 0)); + + final photos = await repository.getPhotosByAlbumId('mime_mkv'.hashCode); + expect(photos.first.mimeType, 'video/x-matroska'); + }); + }); + + group('supported file extensions', () { + test('should recognize all image extensions', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'ext_test'), + ).create(); + final extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']; + + for (final ext in extensions) { + File( + p.join(albumDir.path, 'file$ext'), + ).writeAsBytesSync(List.filled(10, 0)); + } + + final photos = await repository.getPhotosByAlbumId('ext_test'.hashCode); + expect(photos.length, extensions.length); + }); + + test('should recognize all video extensions', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'ext_video'), + ).create(); + final extensions = ['.mp4', '.avi', '.mov', '.mkv']; + + for (final ext in extensions) { + File( + p.join(albumDir.path, 'video$ext'), + ).writeAsBytesSync(List.filled(10, 0)); + } + + final photos = await repository.getPhotosByAlbumId('ext_video'.hashCode); + expect(photos.length, extensions.length); + }); + + test('should ignore unsupported extensions', () async { + final albumDir = await Directory( + p.join(tempDir.path, 'ext_ignore'), + ).create(); + _createTestPng(albumDir, 'valid.png'); + File(p.join(albumDir.path, 'skip.txt')).writeAsString('text'); + File(p.join(albumDir.path, 'skip.pdf')).writeAsBytesSync([]); + File(p.join(albumDir.path, 'skip.exe')).writeAsBytesSync([]); + + final photos = await repository.getPhotosByAlbumId('ext_ignore'.hashCode); + expect(photos.length, 1); + expect(photos.first.fileName, 'valid.png'); + }); + }); +} diff --git a/test/unit/middleware/exception_handler_test.dart b/test/unit/middleware/exception_handler_test.dart new file mode 100644 index 0000000..c5f7100 --- /dev/null +++ b/test/unit/middleware/exception_handler_test.dart @@ -0,0 +1,133 @@ +import 'dart:io'; + +import 'package:shelf/shelf.dart'; +import 'package:test/test.dart'; + +import '../../../bin/middleware/exception_handler.dart'; + +void main() { + group('exceptionHandler middleware', () { + late Middleware middleware; + + setUp(() { + middleware = exceptionHandler(); + }); + + test('should pass through successful requests', () async { + final handler = middleware((request) { + return Response.ok('Hello'); + }); + + final request = Request('GET', Uri.parse('http://localhost/test')); + final response = await handler(request); + + expect(response.statusCode, 200); + expect(await response.readAsString(), 'Hello'); + }); + + test('should return 400 for FormatException', () async { + final handler = middleware((request) { + throw FormatException('invalid format'); + }); + + final request = Request('GET', Uri.parse('http://localhost/test')); + final response = await handler(request); + + expect(response.statusCode, 400); + final body = await response.readAsString(); + expect(body, contains('Invalid parameter format')); + expect(body, contains('invalid format')); + expect(response.headers['content-type'], 'text/plain'); + }); + + test('should return 404 for PathNotFoundException', () async { + final handler = middleware((request) { + throw const PathNotFoundException('/some/path', OSError('not found')); + }); + + final request = Request('GET', Uri.parse('http://localhost/test')); + final response = await handler(request); + + expect(response.statusCode, 404); + final body = await response.readAsString(); + expect(body, contains('Resource not found')); + }); + + test('should return 500 for FileSystemException', () async { + final handler = middleware((request) { + throw const FileSystemException('disk error'); + }); + + final request = Request('GET', Uri.parse('http://localhost/test')); + final response = await handler(request); + + expect(response.statusCode, 500); + final body = await response.readAsString(); + expect(body, contains('File system error')); + expect(body, contains('disk error')); + }); + + test('should return 500 for generic Exception', () async { + final handler = middleware((request) { + throw Exception('something went wrong'); + }); + + final request = Request('GET', Uri.parse('http://localhost/test')); + final response = await handler(request); + + expect(response.statusCode, 500); + final body = await response.readAsString(); + expect(body, contains('Internal server error')); + expect(body, contains('something went wrong')); + }); + + test('should return 500 for unknown errors', () async { + final handler = middleware((request) { + // Throw an Error (not Exception) to test the catch-all + throw StateError('critical failure'); + }); + + final request = Request('GET', Uri.parse('http://localhost/test')); + final response = await handler(request); + + expect(response.statusCode, 500); + final body = await response.readAsString(); + expect(body, 'An unexpected error occurred'); + }); + + test('should handle async exceptions', () async { + final handler = middleware((request) async { + await Future.delayed(Duration.zero); + throw FormatException('async format error'); + }); + + final request = Request('GET', Uri.parse('http://localhost/test')); + final response = await handler(request); + + expect(response.statusCode, 400); + final body = await response.readAsString(); + expect(body, contains('async format error')); + }); + + test('should handle nested middleware handlers', () async { + final innerHandler = (Request request) async { + return Response.ok('nested'); + }; + final wrappedHandler = _addProcessingHeader(innerHandler); + final handler = middleware(wrappedHandler); + + final request = Request('GET', Uri.parse('http://localhost/test')); + final response = await handler(request); + + expect(response.statusCode, 200); + expect(response.headers['x-processed'], 'true'); + }); + }); +} + +Handler _addProcessingHeader(Handler inner) { + return (Request request) async { + final response = await inner(request); + return response.change(headers: {'x-processed': 'true'}); + }; +} diff --git a/test/unit/util/vips_test.dart b/test/unit/util/vips_test.dart new file mode 100644 index 0000000..79ee932 --- /dev/null +++ b/test/unit/util/vips_test.dart @@ -0,0 +1,190 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../../../bin/util/vips.dart'; + +void main() { + late Directory tempDir; + late Directory cacheDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('vips_test_'); + cacheDir = await Directory.systemTemp.createTemp('vips_cache_'); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + if (await cacheDir.exists()) { + await cacheDir.delete(recursive: true); + } + }); + + group('Vips', () { + test('should construct with required parameters', () { + final v = Vips(cacheDir: '/some/cache/path'); + + expect(v.cacheDir, '/some/cache/path'); + expect(v.vipsExecuteFile, isNull); + }); + + test('should construct with custom vips executable', () { + final v = Vips( + vipsExecuteFile: '/custom/path/vips', + cacheDir: '/some/cache/path', + ); + + expect(v.vipsExecuteFile, '/custom/path/vips'); + expect(v.cacheDir, '/some/cache/path'); + }); + }); + + group('generatePreview', () { + test('should throw when source file does not exist', () async { + final v = Vips(cacheDir: cacheDir.path); + + expect( + () => v.generatePreview('/nonexistent/file.jpg'), + throwsA(isA()), + ); + }); + + test('should throw when vips is not installed', () async { + final v = Vips(cacheDir: cacheDir.path); + + // Create a dummy file but vips likely isn't installed in test env + final dummyFile = File(p.join(tempDir.path, 'dummy.jpg')); + dummyFile.writeAsBytesSync(List.filled(100, 0)); + + expect( + () => v.generatePreview(dummyFile.path), + throwsA(isA()), + ); + }); + + test('should create cache directory if it does not exist', () async { + final customCacheDir = p.join(tempDir.path, 'new_cache'); + final previewDirPath = p.join(customCacheDir, 'preview'); + final v = Vips(cacheDir: customCacheDir); + + final dummyFile = File(p.join(tempDir.path, 'test.png')); + dummyFile.writeAsBytesSync(List.filled(100, 0)); + + // This will likely fail due to missing vips, but we verify + // the cache directory creation attempt happens before the process call + try { + await v.generatePreview(dummyFile.path); + } catch (_) { + // Expected - vips not installed + } + + // The preview subdirectory should have been created + expect(Directory(previewDirPath).existsSync(), isTrue); + }); + }); + + group('caching behavior', () { + test('should return cached file when it already exists', () async { + final v = Vips(cacheDir: cacheDir.path); + + // Create source file + final sourceFile = File(p.join(tempDir.path, 'test_image.jpg')); + sourceFile.writeAsBytesSync(List.filled(100, 0)); + + // Pre-create the expected cached file + final baseName = p.basename(sourceFile.path).hashCode.toString(); + final cachedFile = File( + p.join(cacheDir.path, 'preview', '${baseName}_100x100.webp'), + ); + cachedFile.parent.createSync(recursive: true); + cachedFile.writeAsBytesSync([0x52, 0x49, 0x46, 0x46]); // RIFF header + + // Now call generatePreview - it should return the cached file + final result = await v.generatePreview(sourceFile.path, w: 100, h: 100); + + expect(p.normalize(result.path), p.normalize(cachedFile.path)); + expect(result.existsSync(), isTrue); + }); + + test('should not call vips when cache hit', () async { + final v = Vips(cacheDir: cacheDir.path); + + // Create source file + final sourceFile = File(p.join(tempDir.path, 'cached.jpg')); + sourceFile.writeAsBytesSync(List.filled(100, 0)); + + // Create the expected cached file + final baseName = p.basename(sourceFile.path).hashCode.toString(); + final cachedFile = File( + p.join(cacheDir.path, 'preview', '${baseName}_200x100.webp'), + ); + cachedFile.parent.createSync(recursive: true); + cachedFile.writeAsBytesSync([0x01, 0x02, 0x03]); + + final result = await v.generatePreview(sourceFile.path, w: 200, h: 100); + + // Should return the cached file without calling vips + expect(p.normalize(result.path), p.normalize(cachedFile.path)); + expect(result.existsSync(), isTrue); + }); + + test( + 'should use correct cache filename format for different sizes', + () async { + final v = Vips(cacheDir: cacheDir.path); + + // Create source file + final sourceFile = File(p.join(tempDir.path, 'sizing.jpg')); + sourceFile.writeAsBytesSync(List.filled(100, 0)); + + // Create cached file for 400x300 + final baseName = p.basename(sourceFile.path).hashCode.toString(); + final cachedFile = File( + p.join(cacheDir.path, 'preview', '${baseName}_400x300.webp'), + ); + cachedFile.parent.createSync(recursive: true); + cachedFile.writeAsBytesSync([0xAA]); + + final result = await v.generatePreview(sourceFile.path, w: 400, h: 300); + expect(p.normalize(result.path), p.normalize(cachedFile.path)); + }, + ); + + test('should use correct cache filename format for width only', () async { + final v = Vips(cacheDir: cacheDir.path); + + final sourceFile = File(p.join(tempDir.path, 'width_only.jpg')); + sourceFile.writeAsBytesSync(List.filled(100, 0)); + + final baseName = p.basename(sourceFile.path).hashCode.toString(); + final cachedFile = File( + p.join(cacheDir.path, 'preview', '${baseName}_500x0.webp'), + ); + cachedFile.parent.createSync(recursive: true); + cachedFile.writeAsBytesSync([0xBB]); + + final result = await v.generatePreview(sourceFile.path, w: 500); + expect(p.normalize(result.path), p.normalize(cachedFile.path)); + }); + + test('should use correct cache filename format for height only', () async { + final v = Vips(cacheDir: cacheDir.path); + + final sourceFile = File(p.join(tempDir.path, 'height_only.jpg')); + sourceFile.writeAsBytesSync(List.filled(100, 0)); + + final baseName = p.basename(sourceFile.path).hashCode.toString(); + final cachedFile = File( + p.join(cacheDir.path, 'preview', '${baseName}_0x600.webp'), + ); + cachedFile.parent.createSync(recursive: true); + cachedFile.writeAsBytesSync([0xCC]); + + final result = await v.generatePreview(sourceFile.path, h: 600); + expect(p.normalize(result.path), p.normalize(cachedFile.path)); + }); + }); +}