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