2026-09-19 00:40:02 +08:00
|
|
|
|
/// 相册域模型往返测试(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<String, dynamic> albumSummaryFixture = <String, dynamic>{
|
|
|
|
|
|
'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,
|
2026-09-21 14:09:35 +08:00
|
|
|
|
'cover': null,
|
2026-09-19 00:40:02 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
final Map<String, dynamic> categoryFixture = <String, dynamic>{
|
|
|
|
|
|
'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));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-21 14:09:35 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-19 00:40:02 +08:00
|
|
|
|
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 = <String, dynamic>{
|
|
|
|
|
|
'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 = <String, dynamic>{
|
|
|
|
|
|
'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,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|