feat(contracts,api): UI-01 cover, preview, scope=own and embed config extensions

契约扩展(全部可选/可空,旧客户端零感知)+ cn/ov 实现 + e2e 权限矩阵:
- AlbumSummary += cover(kind=cover state=effective 最新行的短期签名 URL,
  列表/详情按页批量组装非 N+1;未配置为 null)
- AdminPhoto += preview{thumb,browse}(变体齐备且未删除才签发;隐藏仅
  admin/owner;协作者仅自己上传,scope=own 服务端过滤 uploaderId,
  成员资格逐请求裁决)
- listAlbumPhotos += scope=all|own(缺省 all 行为不变:仅 owner/admin,
  协作者 403;own 任何有效成员)
- EmbedSlotSettings += imageFit/offsetX/offsetBottom/zIndex(cn PATCH 校验
  → slot-state 投影 → ov 公开回读;settings 为 Json 列免迁移,读侧
  normalizeStoredSettings 兜旧形状默认值)
- Flutter 模型同步(SignedUrl/PhotoPreview/AlbumPhotoScope,可空容错)

e2e:ui01-contracts 9 例(封面选取/字节取回/N+1 计数断言、preview 权限
矩阵、scope 过滤与 403/404/400)+ embed ⑨ 配置链路;全量 e2e 257 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 14:09:35 +08:00
parent 3b5f8d2d77
commit 8f4ad11bd5
21 changed files with 1428 additions and 40 deletions
@@ -128,6 +128,7 @@ Map<String, dynamic> albumBody() => <String, dynamic>{
'createdAt': '2026-09-10T08:00:00Z',
'updatedAt': '2026-09-10T08:00:00Z',
'statsSyncedAt': null,
'cover': null,
'categories': [],
};
@@ -48,6 +48,7 @@ final Map<String, dynamic> albumSummaryFixture = <String, dynamic>{
'createdAt': '2026-09-10T08:00:00.000Z',
'updatedAt': '2026-09-10T08:00:00.000Z',
'statsSyncedAt': null,
'cover': null,
};
final Map<String, dynamic> categoryFixture = <String, dynamic>{
@@ -96,6 +97,26 @@ void main() {
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);
@@ -75,14 +75,46 @@ void main() {
'account': 'collab01',
'displayName': '李四',
},
'preview': null,
};
final model = AdminPhoto.fromJson(fixture);
// e2e:uploader 映射取的是资料 displayName,而非账号。
expect(model.uploader.displayName, '李四');
expect(model.uploader.userId, model.uploaderId);
expect(model.preview, isNull);
expect(model.toJson(), equals(fixture));
});
test('AdminPhoto preview(UI-01):非空往返与缺键容错', () {
final withPreview = {
...photoRecordFixture,
'uploader': {
'userId': 'usr-col-02',
'account': 'collab01',
'displayName': '李四',
},
'preview': {
'thumb': {
'url': 'http://minio:9000/bucket/variants/thumb/p1.webp?X-Amz-Signature=t',
'expiresAt': '2026-09-21T09:00:00.000Z',
},
'browse': {
'url': 'http://minio:9000/bucket/variants/browse/p1.webp?X-Amz-Signature=b',
'expiresAt': '2026-09-21T09:00:00.000Z',
},
},
};
final model = AdminPhoto.fromJson(withPreview);
expect(model.preview, isNotNull);
expect(model.preview!.thumb.url, contains('/thumb/'));
expect(model.preview!.browse.expiresAt, DateTime.utc(2026, 9, 21, 9));
expect(model.toJson(), equals(withPreview));
// 旧响应缺 preview 键 → null(旧响应不 break)。
final legacy = {...withPreview}..remove('preview');
expect(AdminPhoto.fromJson(legacy).preview, isNull);
});
test('PhotoVisibility / PhotoProcessingState 全枚举解析', () {
for (final value in const [
'unpublished',
+2
View File
@@ -211,6 +211,7 @@ Map<String, dynamic> albumJson({
'createdAt': '2026-09-01T00:00:00Z',
'updatedAt': '2026-09-01T00:00:00Z',
'statsSyncedAt': null,
'cover': null,
};
/// 契约 `Category` 响应体。
@@ -285,6 +286,7 @@ Map<String, dynamic> adminPhotoJson({
'account': 'staff02',
'displayName': 'Staff Two',
},
'preview': null,
};
/// 契约游标分页响应体(hasMore 与 nextCursor 成对)。