格式化代码
This commit is contained in:
1574
.idea/caches/deviceStreaming.xml
generated
Normal file
1574
.idea/caches/deviceStreaming.xml
generated
Normal file
File diff suppressed because it is too large
Load Diff
103
bin/router.dart
103
bin/router.dart
@@ -22,29 +22,63 @@ Response jsonResponse(dynamic data) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建 API v1 版本的路由
|
/// 路由依赖注入容器
|
||||||
Router createApiV1Router() {
|
///
|
||||||
final router = Router()
|
/// 允许在测试中使用 mock 或临时目录,而不是全局单例。
|
||||||
..get('/', _rootHandler)
|
class AppRouter {
|
||||||
..get("/album", _listAlbumHandler)
|
final AlbumRepository albumRepository;
|
||||||
..get("/album/<id>", _getAlbumHandler)
|
final PhotoRepository photoRepository;
|
||||||
..get("/album/<id>/photo", _getPhotosOfAlbumHandler)
|
final Vips vips;
|
||||||
..get('/photo/<id>', _getPhotoHandler)
|
|
||||||
..get('/photo/<id>/file', _getPhotoFileHandler)
|
|
||||||
..get('/photo/<id>/preview', _getPhotoPreviewHandler)
|
|
||||||
..get('/echo/<message>', _echoHandler);
|
|
||||||
|
|
||||||
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/<id>', (req) => _getAlbumHandler(req, albumRepository))
|
||||||
|
..get(
|
||||||
|
'/album/<id>/photo',
|
||||||
|
(req) => _getPhotosOfAlbumHandler(req, photoRepository),
|
||||||
|
)
|
||||||
|
..get('/photo/<id>', (req) => _getPhotoHandler(req, photoRepository))
|
||||||
|
..get(
|
||||||
|
'/photo/<id>/file',
|
||||||
|
(req) => _getPhotoFileHandler(req, photoRepository),
|
||||||
|
)
|
||||||
|
..get(
|
||||||
|
'/photo/<id>/preview',
|
||||||
|
(req) => _getPhotoPreviewHandler(req, photoRepository, vips),
|
||||||
|
)
|
||||||
|
..get('/echo/<message>', _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() {
|
Router createRouter() {
|
||||||
final router = Router();
|
return AppRouter().createRouter();
|
||||||
|
|
||||||
// 将 API v1 路由挂载到 /api/v1 路径下
|
|
||||||
router.mount('/api/v1', createApiV1Router().call);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Response _rootHandler(Request req) {
|
Response _rootHandler(Request req) {
|
||||||
@@ -56,8 +90,8 @@ Response _echoHandler(Request request) {
|
|||||||
return Response.ok('$message\n');
|
return Response.ok('$message\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response> _listAlbumHandler(Request req) async {
|
Future<Response> _listAlbumHandler(Request req, AlbumRepository repo) async {
|
||||||
final albums = await albumRepository.getAllAlbums();
|
final albums = await repo.getAllAlbums();
|
||||||
final albumList = albums
|
final albumList = albums
|
||||||
.map(
|
.map(
|
||||||
(a) => {
|
(a) => {
|
||||||
@@ -71,7 +105,7 @@ Future<Response> _listAlbumHandler(Request req) async {
|
|||||||
return jsonResponse(albumList);
|
return jsonResponse(albumList);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response> _getAlbumHandler(Request req) async {
|
Future<Response> _getAlbumHandler(Request req, AlbumRepository repo) async {
|
||||||
final idParam = req.params['id'];
|
final idParam = req.params['id'];
|
||||||
if (idParam == null) {
|
if (idParam == null) {
|
||||||
return Response.badRequest(body: 'Missing album id');
|
return Response.badRequest(body: 'Missing album id');
|
||||||
@@ -80,14 +114,14 @@ Future<Response> _getAlbumHandler(Request req) async {
|
|||||||
if (id == null) {
|
if (id == null) {
|
||||||
return Response.badRequest(body: 'Invalid album id: $idParam');
|
return Response.badRequest(body: 'Invalid album id: $idParam');
|
||||||
}
|
}
|
||||||
final album = await albumRepository.getAlbumById(id);
|
final album = await repo.getAlbumById(id);
|
||||||
if (album == null) {
|
if (album == null) {
|
||||||
return Response.notFound('Album not found');
|
return Response.notFound('Album not found');
|
||||||
}
|
}
|
||||||
return jsonResponse({'id': album.id, 'name': album.name});
|
return jsonResponse({'id': album.id, 'name': album.name});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response> _getPhotoHandler(Request req) async {
|
Future<Response> _getPhotoHandler(Request req, PhotoRepository repo) async {
|
||||||
final idParam = req.params['id'];
|
final idParam = req.params['id'];
|
||||||
if (idParam == null) {
|
if (idParam == null) {
|
||||||
return Response.badRequest(body: 'Missing photo id');
|
return Response.badRequest(body: 'Missing photo id');
|
||||||
@@ -97,14 +131,14 @@ Future<Response> _getPhotoHandler(Request req) async {
|
|||||||
return Response.badRequest(body: 'Invalid photo id: $idParam');
|
return Response.badRequest(body: 'Invalid photo id: $idParam');
|
||||||
}
|
}
|
||||||
|
|
||||||
final photo = await photoRepository.getPhotoById(id);
|
final photo = await repo.getPhotoById(id);
|
||||||
if (photo == null) {
|
if (photo == null) {
|
||||||
return Response.notFound('Photo not found');
|
return Response.notFound('Photo not found');
|
||||||
}
|
}
|
||||||
return jsonResponse(photo);
|
return jsonResponse(photo);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response> _getPhotoFileHandler(Request req) async {
|
Future<Response> _getPhotoFileHandler(Request req, PhotoRepository repo) async {
|
||||||
final idParam = req.params['id'];
|
final idParam = req.params['id'];
|
||||||
if (idParam == null) {
|
if (idParam == null) {
|
||||||
return Response.badRequest(body: 'Missing photo id');
|
return Response.badRequest(body: 'Missing photo id');
|
||||||
@@ -114,7 +148,7 @@ Future<Response> _getPhotoFileHandler(Request req) async {
|
|||||||
return Response.badRequest(body: 'Invalid photo id: $idParam');
|
return Response.badRequest(body: 'Invalid photo id: $idParam');
|
||||||
}
|
}
|
||||||
|
|
||||||
final photo = await photoRepository.getPhotoById(id);
|
final photo = await repo.getPhotoById(id);
|
||||||
if (photo == null) {
|
if (photo == null) {
|
||||||
return Response.notFound('Photo not found');
|
return Response.notFound('Photo not found');
|
||||||
}
|
}
|
||||||
@@ -153,7 +187,10 @@ Future<Response> _getPhotoFileHandler(Request req) async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response> _getPhotosOfAlbumHandler(Request req) async {
|
Future<Response> _getPhotosOfAlbumHandler(
|
||||||
|
Request req,
|
||||||
|
PhotoRepository repo,
|
||||||
|
) async {
|
||||||
final idParam = req.params['id'];
|
final idParam = req.params['id'];
|
||||||
if (idParam == null) {
|
if (idParam == null) {
|
||||||
return Response.badRequest(body: 'Missing album id');
|
return Response.badRequest(body: 'Missing album id');
|
||||||
@@ -162,11 +199,15 @@ Future<Response> _getPhotosOfAlbumHandler(Request req) async {
|
|||||||
if (albumId == null) {
|
if (albumId == null) {
|
||||||
return Response.badRequest(body: 'Invalid album id: $idParam');
|
return Response.badRequest(body: 'Invalid album id: $idParam');
|
||||||
}
|
}
|
||||||
final photos = await photoRepository.getPhotosByAlbumId(albumId);
|
final photos = await repo.getPhotosByAlbumId(albumId);
|
||||||
return jsonResponse(photos);
|
return jsonResponse(photos);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response> _getPhotoPreviewHandler(Request req) async {
|
Future<Response> _getPhotoPreviewHandler(
|
||||||
|
Request req,
|
||||||
|
PhotoRepository repo,
|
||||||
|
Vips vips,
|
||||||
|
) async {
|
||||||
final idParam = req.params['id'];
|
final idParam = req.params['id'];
|
||||||
if (idParam == null) {
|
if (idParam == null) {
|
||||||
return Response.badRequest(body: 'Missing photo id');
|
return Response.badRequest(body: 'Missing photo id');
|
||||||
@@ -176,7 +217,7 @@ Future<Response> _getPhotoPreviewHandler(Request req) async {
|
|||||||
return Response.badRequest(body: 'Invalid photo id: $idParam');
|
return Response.badRequest(body: 'Invalid photo id: $idParam');
|
||||||
}
|
}
|
||||||
|
|
||||||
final photo = await photoRepository.getPhotoById(id);
|
final photo = await repo.getPhotoById(id);
|
||||||
if (photo == null) {
|
if (photo == null) {
|
||||||
return Response.notFound('Photo not found');
|
return Response.notFound('Photo not found');
|
||||||
}
|
}
|
||||||
|
|||||||
505
test/integration/server_test.dart
Normal file
505
test/integration/server_test.dart
Normal file
@@ -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<int> _createTestPng({int width = 100, int height = 100}) {
|
||||||
|
final buffer = BytesBuilder();
|
||||||
|
// PNG signature
|
||||||
|
buffer.addByte(0x89);
|
||||||
|
buffer.addByte(0x50);
|
||||||
|
buffer.addByte(0x4E);
|
||||||
|
buffer.addByte(0x47);
|
||||||
|
buffer.addByte(0x0D);
|
||||||
|
buffer.addByte(0x0A);
|
||||||
|
buffer.addByte(0x1A);
|
||||||
|
buffer.addByte(0x0A);
|
||||||
|
// IHDR chunk length (13 bytes)
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
buffer.addByte(0x0D);
|
||||||
|
// IHDR chunk type
|
||||||
|
buffer.addByte(0x49);
|
||||||
|
buffer.addByte(0x48);
|
||||||
|
buffer.addByte(0x44);
|
||||||
|
buffer.addByte(0x52);
|
||||||
|
// Width (big-endian)
|
||||||
|
buffer.addByte((width >> 24) & 0xFF);
|
||||||
|
buffer.addByte((width >> 16) & 0xFF);
|
||||||
|
buffer.addByte((width >> 8) & 0xFF);
|
||||||
|
buffer.addByte(width & 0xFF);
|
||||||
|
// Height (big-endian)
|
||||||
|
buffer.addByte((height >> 24) & 0xFF);
|
||||||
|
buffer.addByte((height >> 16) & 0xFF);
|
||||||
|
buffer.addByte((height >> 8) & 0xFF);
|
||||||
|
buffer.addByte(height & 0xFF);
|
||||||
|
// Rest of IHDR
|
||||||
|
buffer.addByte(0x08);
|
||||||
|
buffer.addByte(0x02);
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
// CRC (dummy)
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
// IEND
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
buffer.addByte(0x00);
|
||||||
|
buffer.addByte(0x49);
|
||||||
|
buffer.addByte(0x45);
|
||||||
|
buffer.addByte(0x4E);
|
||||||
|
buffer.addByte(0x44);
|
||||||
|
buffer.addByte(0xAE);
|
||||||
|
buffer.addByte(0x42);
|
||||||
|
buffer.addByte(0x60);
|
||||||
|
buffer.addByte(0x82);
|
||||||
|
return buffer.toBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a fresh in-memory server with isolated temp directories.
|
||||||
|
///
|
||||||
|
/// This is the key optimization over the old approach:
|
||||||
|
/// - No subprocess spawning (fast startup)
|
||||||
|
/// - Random port via port 0 (no port conflicts)
|
||||||
|
/// - Per-test temp directories (no data pollution)
|
||||||
|
/// - Reusable HTTP client (connection pooling)
|
||||||
|
class TestServer {
|
||||||
|
late final Directory dataDir;
|
||||||
|
late final Directory cacheDir;
|
||||||
|
late final HttpServer server;
|
||||||
|
late final http.Client client;
|
||||||
|
late final String baseUrl;
|
||||||
|
|
||||||
|
/// Start the server with the given test data (if any).
|
||||||
|
///
|
||||||
|
/// [testAlbums] is a map of album name -> list of (filename, fileData).
|
||||||
|
static Future<TestServer> start({
|
||||||
|
Map<String, List<(String, List<int>)>>? testAlbums,
|
||||||
|
}) async {
|
||||||
|
final s = TestServer._();
|
||||||
|
s.dataDir = await Directory.systemTemp.createTemp('itest_data_');
|
||||||
|
s.cacheDir = await Directory.systemTemp.createTemp('itest_cache_');
|
||||||
|
|
||||||
|
// Create test data if provided
|
||||||
|
if (testAlbums != null) {
|
||||||
|
for (final entry in testAlbums.entries) {
|
||||||
|
final albumDir = Directory(p.join(s.dataDir.path, entry.key));
|
||||||
|
await albumDir.create(recursive: true);
|
||||||
|
for (final (name, data) in entry.value) {
|
||||||
|
File(p.join(albumDir.path, name)).writeAsBytesSync(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final router = AppRouter(
|
||||||
|
albumRepository: AlbumRepository(basePath: s.dataDir.path),
|
||||||
|
photoRepository: PhotoRepository(s.dataDir.path),
|
||||||
|
vips: Vips(cacheDir: s.cacheDir.path),
|
||||||
|
);
|
||||||
|
|
||||||
|
final handler = Pipeline()
|
||||||
|
.addMiddleware(exceptionHandler())
|
||||||
|
.addHandler(router.createRouter().call);
|
||||||
|
|
||||||
|
s.server = await shelf_io.serve(
|
||||||
|
handler,
|
||||||
|
InternetAddress.loopbackIPv4,
|
||||||
|
0, // Random available port
|
||||||
|
);
|
||||||
|
s.client = http.Client();
|
||||||
|
s.baseUrl = 'http://localhost:${s.server.port}';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
TestServer._();
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
client.close();
|
||||||
|
await server.close(force: true);
|
||||||
|
if (await dataDir.exists()) {
|
||||||
|
await dataDir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
if (await cacheDir.exists()) {
|
||||||
|
await cacheDir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<http.Response> get(String path) =>
|
||||||
|
client.get(Uri.parse('$baseUrl$path'));
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Integration tests (in-memory server)', () {
|
||||||
|
late TestServer server;
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await server.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Root endpoint', () {
|
||||||
|
test('GET /api/v1/ returns Hello World', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/');
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect(response.body, 'Hello, World!\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Echo endpoint', () {
|
||||||
|
test('GET /api/v1/echo/<message> returns the message', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/echo/hello');
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect(response.body, 'hello\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/v1/echo/<message> with special characters', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/echo/test%20message');
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect(response.body, contains('test'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Album endpoints', () {
|
||||||
|
test('GET /api/v1/album returns empty list when no albums', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/album');
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect(response.headers['content-type'], contains('application/json'));
|
||||||
|
final albums = jsonDecode(response.body) as List;
|
||||||
|
expect(albums, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/v1/album returns albums when data exists', () async {
|
||||||
|
server = await TestServer.start(
|
||||||
|
testAlbums: {
|
||||||
|
'vacation': [('photo.png', _createTestPng())],
|
||||||
|
'birthday': [('photo2.png', _createTestPng())],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final response = await server.get('/api/v1/album');
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
final albums = jsonDecode(response.body) as List;
|
||||||
|
expect(albums.length, 2);
|
||||||
|
final names = albums.map((a) => a['name'] as String).toSet();
|
||||||
|
expect(names, containsAll(['vacation', 'birthday']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/v1/album/<id> with invalid id returns 400', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/album/invalid_id');
|
||||||
|
expect(response.statusCode, 400);
|
||||||
|
expect(response.body, contains('Invalid album id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/v1/album/<id> with non-existent id returns 404', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/album/999999');
|
||||||
|
expect(response.statusCode, 404);
|
||||||
|
expect(response.body, contains('Album not found'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/v1/album/<id> returns album details', () async {
|
||||||
|
server = await TestServer.start(testAlbums: {'my_album': []});
|
||||||
|
final listResponse = await server.get('/api/v1/album');
|
||||||
|
final albums = jsonDecode(listResponse.body) as List;
|
||||||
|
final albumId = albums.first['id'];
|
||||||
|
|
||||||
|
final response = await server.get('/api/v1/album/$albumId');
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
final album = jsonDecode(response.body) as Map;
|
||||||
|
expect(album['id'], albumId);
|
||||||
|
expect(album['name'], 'my_album');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'GET /api/v1/album/<id>/photo with invalid id returns 400',
|
||||||
|
() async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/album/invalid_id/photo');
|
||||||
|
expect(response.statusCode, 400);
|
||||||
|
expect(response.body, contains('Invalid album id'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('GET /api/v1/album/<id>/photo returns photos', () async {
|
||||||
|
server = await TestServer.start(
|
||||||
|
testAlbums: {
|
||||||
|
'photo_album': [
|
||||||
|
('image.png', _createTestPng(width: 640, height: 480)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final listResponse = await server.get('/api/v1/album');
|
||||||
|
final albums = jsonDecode(listResponse.body) as List;
|
||||||
|
final albumId = albums.first['id'];
|
||||||
|
|
||||||
|
final response = await server.get('/api/v1/album/$albumId/photo');
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
final photos = jsonDecode(response.body) as List;
|
||||||
|
expect(photos, isNotEmpty);
|
||||||
|
expect(photos.first['fileName'], 'image.png');
|
||||||
|
expect(photos.first['mimeType'], 'image/png');
|
||||||
|
expect(photos.first['width'], 640);
|
||||||
|
expect(photos.first['height'], 480);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'GET /api/v1/album/<id>/photo returns empty for empty album',
|
||||||
|
() async {
|
||||||
|
server = await TestServer.start(testAlbums: {'empty_album': []});
|
||||||
|
final listResponse = await server.get('/api/v1/album');
|
||||||
|
final albums = jsonDecode(listResponse.body) as List;
|
||||||
|
final albumId = albums.first['id'];
|
||||||
|
|
||||||
|
final response = await server.get('/api/v1/album/$albumId/photo');
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
final photos = jsonDecode(response.body) as List;
|
||||||
|
expect(photos, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Photo endpoints', () {
|
||||||
|
test('GET /api/v1/photo/<id> with invalid id returns 400', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/photo/abc');
|
||||||
|
expect(response.statusCode, 400);
|
||||||
|
expect(response.body, contains('Invalid photo id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/v1/photo/<id> with non-existent id returns 404', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/photo/12345');
|
||||||
|
expect(response.statusCode, 404);
|
||||||
|
expect(response.body, contains('Photo not found'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/v1/photo/<id> returns photo metadata', () async {
|
||||||
|
server = await TestServer.start(
|
||||||
|
testAlbums: {
|
||||||
|
'photos': [('photo.png', _createTestPng(width: 800, height: 600))],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final listResponse = await server.get('/api/v1/album');
|
||||||
|
final albums = jsonDecode(listResponse.body) as List;
|
||||||
|
final albumId = albums.first['id'];
|
||||||
|
|
||||||
|
final photosResponse = await server.get('/api/v1/album/$albumId/photo');
|
||||||
|
final photos = jsonDecode(photosResponse.body) as List;
|
||||||
|
final photoId = photos.first['id'];
|
||||||
|
|
||||||
|
final response = await server.get('/api/v1/photo/$photoId');
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
final photo = jsonDecode(response.body) as Map;
|
||||||
|
expect(photo['fileName'], 'photo.png');
|
||||||
|
expect(photo['width'], 800);
|
||||||
|
expect(photo['height'], 600);
|
||||||
|
expect(photo['mimeType'], 'image/png');
|
||||||
|
expect(photo, containsPair('createdAt', isA<String>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/v1/photo/<id>/file with invalid id returns 400', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/photo/abc/file');
|
||||||
|
expect(response.statusCode, 400);
|
||||||
|
expect(response.body, contains('Invalid photo id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'GET /api/v1/photo/<id>/file with non-existent id returns 404',
|
||||||
|
() async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/photo/12345/file');
|
||||||
|
expect(response.statusCode, 404);
|
||||||
|
expect(response.body, contains('Photo not found'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('GET /api/v1/photo/<id>/file returns file content', () async {
|
||||||
|
final testData = _createTestPng(width: 100, height: 100);
|
||||||
|
server = await TestServer.start(
|
||||||
|
testAlbums: {
|
||||||
|
'files': [('download.png', testData)],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final listResponse = await server.get('/api/v1/album');
|
||||||
|
final albums = jsonDecode(listResponse.body) as List;
|
||||||
|
final albumId = albums.first['id'];
|
||||||
|
|
||||||
|
final photosResponse = await server.get('/api/v1/album/$albumId/photo');
|
||||||
|
final photos = jsonDecode(photosResponse.body) as List;
|
||||||
|
final photoId = photos.first['id'];
|
||||||
|
|
||||||
|
final response = await server.get('/api/v1/photo/$photoId/file');
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect(response.contentLength, greaterThan(0));
|
||||||
|
expect(response.bodyBytes, testData);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'GET /api/v1/photo/<id>/file returns correct content type',
|
||||||
|
() async {
|
||||||
|
server = await TestServer.start(
|
||||||
|
testAlbums: {
|
||||||
|
'types': [('image.png', _createTestPng())],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final listResponse = await server.get('/api/v1/album');
|
||||||
|
final albums = jsonDecode(listResponse.body) as List;
|
||||||
|
final albumId = albums.first['id'];
|
||||||
|
|
||||||
|
final photosResponse = await server.get(
|
||||||
|
'/api/v1/album/$albumId/photo',
|
||||||
|
);
|
||||||
|
final photos = jsonDecode(photosResponse.body) as List;
|
||||||
|
final photoId = photos.first['id'];
|
||||||
|
|
||||||
|
final response = await server.get('/api/v1/photo/$photoId/file');
|
||||||
|
expect(response.headers['content-type'], contains('image/png'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Preview endpoint', () {
|
||||||
|
test(
|
||||||
|
'GET /api/v1/photo/<id>/preview with invalid id returns 400',
|
||||||
|
() async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/photo/abc/preview');
|
||||||
|
expect(response.statusCode, 400);
|
||||||
|
expect(response.body, contains('Invalid photo id'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'GET /api/v1/photo/<id>/preview with non-existent id returns 404',
|
||||||
|
() async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/photo/12345/preview');
|
||||||
|
expect(response.statusCode, 404);
|
||||||
|
expect(response.body, contains('Photo not found'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'GET /api/v1/photo/<id>/preview throws when vips not installed',
|
||||||
|
() async {
|
||||||
|
server = await TestServer.start(
|
||||||
|
testAlbums: {
|
||||||
|
'preview': [('image.png', _createTestPng())],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final listResponse = await server.get('/api/v1/album');
|
||||||
|
final albums = jsonDecode(listResponse.body) as List;
|
||||||
|
final albumId = albums.first['id'];
|
||||||
|
|
||||||
|
final photosResponse = await server.get(
|
||||||
|
'/api/v1/album/$albumId/photo',
|
||||||
|
);
|
||||||
|
final photos = jsonDecode(photosResponse.body) as List;
|
||||||
|
final photoId = photos.first['id'];
|
||||||
|
|
||||||
|
// This will fail because vips CLI is not available,
|
||||||
|
// but the handler should return 500, not crash
|
||||||
|
final response = await server.get(
|
||||||
|
'/api/v1/photo/$photoId/preview?w=100&h=100',
|
||||||
|
);
|
||||||
|
expect(response.statusCode, 500);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('404 handling', () {
|
||||||
|
test('Unknown route returns 404', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/foobar');
|
||||||
|
expect(response.statusCode, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unknown nested route returns 404', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v2/unknown');
|
||||||
|
expect(response.statusCode, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unknown API endpoint returns 404', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/nonexistent');
|
||||||
|
expect(response.statusCode, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Response format', () {
|
||||||
|
test('album endpoint returns valid JSON', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/album');
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect(response.headers['content-type'], contains('application/json'));
|
||||||
|
expect(() => jsonDecode(response.body), returnsNormally);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('photo metadata has all required fields', () async {
|
||||||
|
server = await TestServer.start(
|
||||||
|
testAlbums: {
|
||||||
|
'format': [('test.png', _createTestPng())],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final listResponse = await server.get('/api/v1/album');
|
||||||
|
final albums = jsonDecode(listResponse.body) as List;
|
||||||
|
final albumId = albums.first['id'];
|
||||||
|
|
||||||
|
final photosResponse = await server.get('/api/v1/album/$albumId/photo');
|
||||||
|
final photos = jsonDecode(photosResponse.body) as List;
|
||||||
|
final photoId = photos.first['id'];
|
||||||
|
|
||||||
|
final response = await server.get('/api/v1/photo/$photoId');
|
||||||
|
final photo = jsonDecode(response.body) as Map;
|
||||||
|
|
||||||
|
expect(photo, containsPair('id', isA<int>()));
|
||||||
|
expect(photo, containsPair('albumId', isA<int>()));
|
||||||
|
expect(photo, containsPair('filePath', isA<String>()));
|
||||||
|
expect(photo, containsPair('fileName', isA<String>()));
|
||||||
|
expect(photo, containsPair('fileSize', isA<int>()));
|
||||||
|
expect(photo, containsPair('mimeType', isA<String>()));
|
||||||
|
expect(photo, containsPair('createdAt', isA<String>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Test isolation', () {
|
||||||
|
test('each test starts with clean data', () async {
|
||||||
|
// Start a server, add an album, stop it
|
||||||
|
server = await TestServer.start(testAlbums: {'temp_album': []});
|
||||||
|
final response = await server.get('/api/v1/album');
|
||||||
|
final albums = jsonDecode(response.body) as List;
|
||||||
|
expect(albums.length, 1);
|
||||||
|
// This test's data is isolated - the next test won't see it
|
||||||
|
});
|
||||||
|
|
||||||
|
test('previous test data is not visible', () async {
|
||||||
|
server = await TestServer.start();
|
||||||
|
final response = await server.get('/api/v1/album');
|
||||||
|
final albums = jsonDecode(response.body) as List;
|
||||||
|
// Should be empty because previous test's data was cleaned up
|
||||||
|
expect(albums, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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/<message> 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/<message> 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<List>());
|
|
||||||
if (albums.isNotEmpty) {
|
|
||||||
expect(albums.first, containsPair('id', isA<int>()));
|
|
||||||
expect(albums.first, containsPair('name', isA<String>()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /api/v1/album/<id> 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/<id> 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/<id>/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/<id>/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<String, dynamic>;
|
|
||||||
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<List>());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('Photo endpoints', () {
|
|
||||||
test('GET /api/v1/photo/<id> 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/<id> 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/<id>/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/<id>/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/<id>/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<String, dynamic>;
|
|
||||||
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<String, dynamic>;
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
72
test/unit/config/app_config_test.dart
Normal file
72
test/unit/config/app_config_test.dart
Normal file
@@ -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<String>());
|
||||||
|
expect(config.dataDir, isNotEmpty);
|
||||||
|
expect(config.cacheDir, isA<String>());
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
64
test/unit/domain/entities/album_test.dart
Normal file
64
test/unit/domain/entities/album_test.dart
Normal file
@@ -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<Map<String, dynamic>>());
|
||||||
|
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'], '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
127
test/unit/domain/entities/photo_test.dart
Normal file
127
test/unit/domain/entities/photo_test.dart
Normal file
@@ -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<Map<String, dynamic>>());
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
138
test/unit/domain/repositories/album_repository_test.dart
Normal file
138
test/unit/domain/repositories/album_repository_test.dart
Normal file
@@ -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<DateTime>());
|
||||||
|
expect(albums.first.updatedAt, isA<DateTime>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return Album objects', () async {
|
||||||
|
await Directory(p.join(tempDir.path, 'my_album')).create();
|
||||||
|
|
||||||
|
final albums = await repository.getAllAlbums();
|
||||||
|
|
||||||
|
expect(albums.first, isA<Album>());
|
||||||
|
expect(albums.first.id, isA<int>());
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
613
test/unit/domain/repositories/photo_repository_test.dart
Normal file
613
test/unit/domain/repositories/photo_repository_test.dart
Normal file
@@ -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<DateTime>());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
133
test/unit/middleware/exception_handler_test.dart
Normal file
133
test/unit/middleware/exception_handler_test.dart
Normal file
@@ -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'});
|
||||||
|
};
|
||||||
|
}
|
||||||
190
test/unit/util/vips_test.dart
Normal file
190
test/unit/util/vips_test.dart
Normal file
@@ -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<Exception>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<Exception>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user