/// 相册域模型往返测试(P3 计划 Task 2)。 /// /// fixture 来源(字段逐一转写): /// - openapi.yaml components.schemas.AlbumSummary / Album / /// AlbumTranslation / Category / CategoryTranslation / Member / /// AdminUserLookupItem / AlbumCreateRequest / AlbumUpdateRequest / /// CategoryCreateRequest / CategoryUpdateRequest; /// - services/api/test/albums.e2e-spec.ts `validCreateBody`(zh-CN 完整 + /// en 仅 title、未填可选字段回读 null)与 201 响应形状断言(status= /// active、四开关默认值、watermarkVersion=0、categories=[]、 /// publicToken ^[0-9a-z]{20,32}$)。 library; import 'package:flutter_test/flutter_test.dart'; import 'package:photo_live/features/albums/models.dart'; // openapi.yaml:AlbumSummary 必返字段全集(+ Album 增量 categories)。 final Map albumSummaryFixture = { 'id': 'cktragt000001', 'publicToken': 'a1b2c3d4e5f6g7h8i9j0', 'ownerId': 'usr-owner-01', 'status': 'active', 'defaultLocale': 'zh-CN', 'timezone': 'Asia/Shanghai', 'startAt': '2026-10-01T02:00:00.000Z', 'endAt': null, 'uploadEnabled': true, 'publicEnabled': true, 'downloadEnabled': true, 'originalDownloadEnabled': false, 'watermarkVersion': 0, 'translations': [ { 'locale': 'zh-CN', 'title': '2026 公司年会', 'description': '年度盛会', 'venue': '大宴会厅', 'address': '上海市浦东新区世纪大道 100 号', }, { 'locale': 'en', 'title': 'Annual Party 2026', 'description': null, 'venue': null, 'address': null, }, ], 'createdAt': '2026-09-10T08:00:00.000Z', 'updatedAt': '2026-09-10T08:00:00.000Z', 'statsSyncedAt': null, 'cover': null, }; final Map categoryFixture = { 'id': 'ckucat0000001', 'albumId': 'cktragt000001', 'sortOrder': 0, 'translations': [ {'locale': 'zh-CN', 'name': '开幕式'}, {'locale': 'en', 'name': 'Opening'}, ], }; void main() { test('AlbumSummary fromJson/toJson 往返(e2e 201 形状)', () { final model = AlbumSummary.fromJson(albumSummaryFixture); expect(model.status, AlbumStatus.active); expect(model.publicToken, matches(RegExp('^[0-9a-z]{20,32}\$'))); expect(model.translations, hasLength(2)); // e2e:未填可选字段回读 null(契约「存储为 null」)。 expect(model.translations[1].description, isNull); expect(model.translations[1].venue, isNull); expect(model.translations[1].address, isNull); expect(model.toJson(), equals(albumSummaryFixture)); }); test('Album fromJson/toJson 往返(AlbumSummary + categories)', () { final fixture = {...albumSummaryFixture, 'categories': [categoryFixture]}; final model = Album.fromJson(fixture); expect(model.categories, hasLength(1)); expect(model.categories.first.id, 'ckucat0000001'); expect(model.categories.first.translations.first.name, '开幕式'); expect(model.toJson(), equals(fixture)); }); test('AlbumSummary 非空 endAt/statsSyncedAt 往返', () { final fixture = { ...albumSummaryFixture, 'endAt': '2026-10-01T14:00:00.000Z', 'status': 'offline', 'statsSyncedAt': '2026-09-18T01:02:03.500Z', }; final model = AlbumSummary.fromJson(fixture); expect(model.endAt, DateTime.utc(2026, 10, 1, 14)); expect(model.status, AlbumStatus.offline); expect(model.statsSyncedAt!.millisecond, 500); expect(model.toJson(), equals(fixture)); }); test('AlbumSummary cover(UI-01):非空往返与缺键容错', () { final withCover = { ...albumSummaryFixture, 'cover': { 'url': 'http://minio:9000/photo-live/dev/albums/a1/assets/c1.jpg?X-Amz-Signature=s', 'expiresAt': '2026-09-21T09:00:00.000Z', }, }; final model = AlbumSummary.fromJson(withCover); expect(model.cover, isNotNull); expect(model.cover!.url, startsWith('http://minio:9000/')); expect(model.cover!.expiresAt, DateTime.utc(2026, 9, 21, 9)); expect(model.toJson(), equals(withCover)); // 旧响应缺 cover 键 → null(旧响应不 break)。 final legacy = {...albumSummaryFixture}..remove('cover'); expect(AlbumSummary.fromJson(legacy).cover, isNull); }); test('Category fromJson/toJson 往返', () { final model = Category.fromJson(categoryFixture); expect(model.sortOrder, 0); expect(model.toJson(), equals(categoryFixture)); }); test('Member fromJson/toJson 往返(members-categories.e2e 形状)', () { final fixture = { 'userId': 'usr-owner-01', 'account': 'album-owner', 'displayName': '张三', 'role': 'owner', 'status': 'active', }; final model = Member.fromJson(fixture); expect(model.role, MemberRole.owner); expect(model.status, MemberStatus.active); expect(model.toJson(), equals(fixture)); final collaborator = Member.fromJson({...fixture, 'role': 'collaborator'}); expect(collaborator.role, MemberRole.collaborator); }); test('AdminUserLookupItem fromJson/toJson 往返(脱敏三字段)', () { final fixture = { 'id': 'usr-col-02', 'account': 'collab01', 'displayName': '李四', }; final model = AdminUserLookupItem.fromJson(fixture); expect(model.toJson(), equals(fixture)); }); test('AlbumCreateRequest toJson(albums.e2e validCreateBody 同构)', () { final req = AlbumCreateRequest( translations: const [ AlbumTranslation( locale: 'zh-CN', title: '2026 公司年会', description: '年度盛会', venue: '大宴会厅', address: '上海市浦东新区世纪大道 100 号', ), AlbumTranslation(locale: 'en', title: 'Annual Party 2026'), ], defaultLocale: 'zh-CN', startAt: DateTime.utc(2026, 10, 1, 2), ); expect(req.toJson(), { 'translations': [ { 'locale': 'zh-CN', 'title': '2026 公司年会', 'description': '年度盛会', 'venue': '大宴会厅', 'address': '上海市浦东新区世纪大道 100 号', }, { 'locale': 'en', 'title': 'Annual Party 2026', 'description': null, 'venue': null, 'address': null, }, ], 'defaultLocale': 'zh-CN', // Dart toIso8601String 恒带毫秒——与 e2e 体(.000Z,Date.toISOString // 毫秒形)逐字节一致。 'startAt': '2026-10-01T02:00:00.000Z', 'endAt': null, 'timezone': 'Asia/Shanghai', 'downloadEnabled': true, 'originalDownloadEnabled': false, }); }); test('AlbumUpdateRequest toJson 仅序列化提交字段(部分更新语义)', () { expect( const AlbumUpdateRequest(uploadEnabled: false).toJson(), {'uploadEnabled': false}, ); expect( AlbumUpdateRequest( startAt: DateTime.utc(2026, 10, 2, 9, 0, 0), endAt: DateTime.utc(2026, 10, 2, 18), ).toJson(), { 'startAt': '2026-10-02T09:00:00.000Z', 'endAt': '2026-10-02T18:00:00.000Z', }, ); // endAt 显式清除:序列化为 "endAt": null(契约:null 清除)。 expect( const AlbumUpdateRequest(clearEndAt: true).toJson(), {'endAt': null}, ); // 端提交与显式清除同时设置时,清除语义(null)胜出。 expect( AlbumUpdateRequest( endAt: DateTime.utc(2026, 10, 2, 18), clearEndAt: true, ).toJson(), {'endAt': null}, ); }); test('CategoryCreateRequest / CategoryUpdateRequest toJson', () { const translations = [ CategoryTranslation(locale: 'zh-CN', name: '晚宴'), CategoryTranslation(locale: 'en', name: 'Banquet'), ]; expect( const CategoryCreateRequest(translations: translations).toJson(), { 'translations': [ {'locale': 'zh-CN', 'name': '晚宴'}, {'locale': 'en', 'name': 'Banquet'}, ], }, ); expect( const CategoryCreateRequest(sortOrder: 5, translations: translations) .toJson()['sortOrder'], 5, ); expect( const CategoryUpdateRequest(sortOrder: 2).toJson(), {'sortOrder': 2}, ); expect( const CategoryUpdateRequest(translations: translations).toJson(), { 'translations': [ {'locale': 'zh-CN', 'name': '晚宴'}, {'locale': 'en', 'name': 'Banquet'}, ], }, ); }); test('未知枚举值解析抛 FormatException(fail-fast 防脏数据静默)', () { expect( () => AlbumSummary.fromJson({ ...albumSummaryFixture, 'status': 'archived', }), throwsFormatException, ); expect( () => Member.fromJson(const { 'userId': 'u', 'account': 'a', 'displayName': 'd', 'role': 'viewer', 'status': 'active', }), throwsFormatException, ); }); }