feat(mobile): api client, session storage and contract-aligned models
P3 计划 Task 2(apps/mobile):
- core/config.dart:API_BASE/VIEWER_BASE 经 --dart-define 覆盖,缺省
10.0.2.2 模拟器宿主回环;viewer 相册链接构造 {VIEWER_BASE}/a/{token}。
- core/error_codes.dart:errors.ts 17 码 1:1 镜像(含注释),另设 4 个
客户端本地伪码(NETWORK_ERROR/NETWORK_TIMEOUT/CANCELLED/HTTP_ERROR)。
- core/api_client.dart:dio 封装——Bearer 注入(登录端点豁免)、401
驱逐(清会话 + onUnauthorized 回调去重,App 壳注册跳 /login)、
DioException/错误体 {code,message,requestId} 归一 AppException、
通用游标分页助手;auth/albums/members/categories/admin/uploads/photos
全端点方法;riverpod provider 出口。
- core/session_store.dart:SessionStore 抽象 + flutter_secure_storage
实现(token/expiresAt/account 三键,encode/decode 纯函数可测)。
- features/{auth,albums,uploads,photos}/models.dart:手写 fromJson/
toJson,逐字段对齐 openapi.yaml(类注释标注契约 schema/operationId);
locale 按字符串透传(zh-CN 标识符不兼容),业务枚举 Dart 化并在
未知值 fail-fast。
- 测试 66 项:17 码表驱动 + 网络/超时/取消 + 兜底映射、游标边界
(含 CursorPageMeta 不变式违约容错)、模型往返(fixture 转写自
openapi.yaml schema 与 api e2e 断言,来源在测试头注释)、拦截器
行为(手写 FakeAdapter:Bearer 注入/401 驱逐/登录豁免/并发去重/
尽力而为 logout/refresh 滑动续期)。flutter analyze 零告警。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
/// 相册域模型往返测试(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,
|
||||
};
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// 认证域模型往返测试(P3 计划 Task 2)。
|
||||
///
|
||||
/// fixture 来源:openapi.yaml components.schemas.LoginResponse /
|
||||
/// RefreshResponse(必返字段 sessionToken+expiresAt / expiresAt);
|
||||
/// 时间形态取 auth.e2e-spec.ts 直种会话的「Date.now()+3_600_000」毫秒精度,
|
||||
/// 此处用 Dart 规范形(整秒无小数)保证 toJson 往返逐字节一致,
|
||||
/// 毫秒形(.000Z)解析另有冒烟断言。
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/features/auth/models.dart';
|
||||
|
||||
void main() {
|
||||
test('LoginResponse fromJson/toJson 往返', () {
|
||||
final fixture = <String, dynamic>{
|
||||
'sessionToken': 'f2a9c8b17d3e4f5a6b7c8d9e0f1a2b3c',
|
||||
'expiresAt': '2026-09-19T12:30:00.000Z',
|
||||
};
|
||||
final model = LoginResponse.fromJson(fixture);
|
||||
expect(model.sessionToken, 'f2a9c8b17d3e4f5a6b7c8d9e0f1a2b3c');
|
||||
expect(model.expiresAt, DateTime.utc(2026, 9, 19, 12, 30));
|
||||
expect(model.toJson(), equals(fixture));
|
||||
});
|
||||
|
||||
test('LoginResponse 解析毫秒精度时间(服务端 Date.toISOString 形态)', () {
|
||||
final model = LoginResponse.fromJson({
|
||||
'sessionToken': 't',
|
||||
'expiresAt': '2026-09-19T12:30:00.500Z',
|
||||
});
|
||||
expect(model.expiresAt.millisecond, 500);
|
||||
// toJson 规范化为 Dart ISO 输出(同值不同形,服务端 Date.parse 均可)。
|
||||
expect(model.toJson()['expiresAt'], '2026-09-19T12:30:00.500Z');
|
||||
});
|
||||
|
||||
test('RefreshResponse fromJson/toJson 往返', () {
|
||||
final fixture = <String, dynamic>{'expiresAt': '2026-09-20T08:00:00.000Z'};
|
||||
final model = RefreshResponse.fromJson(fixture);
|
||||
expect(model.expiresAt, DateTime.utc(2026, 9, 20, 8));
|
||||
expect(model.toJson(), equals(fixture));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/// 照片域模型往返测试(P3 计划 Task 2)。
|
||||
///
|
||||
/// fixture 来源(字段逐一转写):
|
||||
/// - openapi.yaml components.schemas.PhotoRecord / AdminPhoto /
|
||||
/// AdminPhotoUploader / PhotoVisibility / PhotoProcessingState;
|
||||
/// - services/api/test/photos-admin.e2e-spec.ts 的 ADMIN_PHOTO_KEYS
|
||||
/// (PhotoRecord 必返字段 + uploader 内嵌)与种子形态
|
||||
/// (visibility=published / processingState=ready、categoryId 可空、
|
||||
/// displayName 取资料字段而非账号)。
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/features/photos/models.dart';
|
||||
|
||||
final Map<String, dynamic> photoRecordFixture = <String, dynamic>{
|
||||
'photoId': 'pa-photo-p1',
|
||||
'albumId': 'cktragt000001',
|
||||
'categoryId': 'ckucat0000001',
|
||||
'uploaderId': 'usr-col-02',
|
||||
'visibility': 'published',
|
||||
'processingState': 'ready',
|
||||
'bytes': 4096000,
|
||||
'width': 4032,
|
||||
'height': 3024,
|
||||
'shotAt': '2026-09-12T07:55:00.000Z',
|
||||
'uploadedAt': '2026-09-12T08:05:00.000Z',
|
||||
'publishedAt': '2026-09-12T08:06:00.000Z',
|
||||
'likeCount': 3,
|
||||
};
|
||||
|
||||
void main() {
|
||||
test('PhotoRecord fromJson/toJson 往返(管理端全字段)', () {
|
||||
final model = PhotoRecord.fromJson(photoRecordFixture);
|
||||
expect(model.visibility, PhotoVisibility.published);
|
||||
expect(model.processingState, PhotoProcessingState.ready);
|
||||
expect(model.likeCount, 3);
|
||||
expect(model.toJson(), equals(photoRecordFixture));
|
||||
});
|
||||
|
||||
test('PhotoRecord 可空字段:处理前宽高为 null、未发布为 null', () {
|
||||
final fixture = {
|
||||
...photoRecordFixture,
|
||||
'categoryId': null,
|
||||
'width': null,
|
||||
'height': null,
|
||||
'shotAt': null,
|
||||
'publishedAt': null,
|
||||
'visibility': 'unpublished',
|
||||
'processingState': 'uploaded',
|
||||
};
|
||||
final model = PhotoRecord.fromJson(fixture);
|
||||
expect(model.categoryId, isNull);
|
||||
expect(model.width, isNull);
|
||||
expect(model.height, isNull);
|
||||
expect(model.shotAt, isNull);
|
||||
expect(model.publishedAt, isNull);
|
||||
expect(model.visibility, PhotoVisibility.unpublished);
|
||||
expect(model.processingState, PhotoProcessingState.uploaded);
|
||||
expect(model.toJson(), equals(fixture));
|
||||
});
|
||||
|
||||
test('PhotoRecord 解析毫秒精度时间(服务端 Date.toISOString 形态)', () {
|
||||
final model = PhotoRecord.fromJson({
|
||||
...photoRecordFixture,
|
||||
'uploadedAt': '2026-09-12T08:05:00.000Z',
|
||||
});
|
||||
expect(model.uploadedAt, DateTime.utc(2026, 9, 12, 8, 5));
|
||||
});
|
||||
|
||||
test('AdminPhoto fromJson/toJson 往返(PhotoRecord + uploader 内嵌)', () {
|
||||
final fixture = {
|
||||
...photoRecordFixture,
|
||||
'uploader': {
|
||||
'userId': 'usr-col-02',
|
||||
'account': 'collab01',
|
||||
'displayName': '李四',
|
||||
},
|
||||
};
|
||||
final model = AdminPhoto.fromJson(fixture);
|
||||
// e2e:uploader 映射取的是资料 displayName,而非账号。
|
||||
expect(model.uploader.displayName, '李四');
|
||||
expect(model.uploader.userId, model.uploaderId);
|
||||
expect(model.toJson(), equals(fixture));
|
||||
});
|
||||
|
||||
test('PhotoVisibility / PhotoProcessingState 全枚举解析', () {
|
||||
for (final value in const [
|
||||
'unpublished',
|
||||
'published',
|
||||
'hidden',
|
||||
'deleted',
|
||||
]) {
|
||||
expect(PhotoVisibility.fromJson(value).wire, value);
|
||||
}
|
||||
for (final value in const [
|
||||
'pending_upload',
|
||||
'uploaded',
|
||||
'processing',
|
||||
'ready',
|
||||
'processing_failed',
|
||||
]) {
|
||||
expect(PhotoProcessingState.fromJson(value).wire, value);
|
||||
}
|
||||
expect(() => PhotoVisibility.fromJson('archived'), throwsFormatException);
|
||||
expect(
|
||||
() => PhotoProcessingState.fromJson('failed'),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/// 上传域模型往返测试(P3 计划 Task 2)。
|
||||
///
|
||||
/// fixture 来源(字段逐一转写):
|
||||
/// - openapi.yaml components.schemas.UploadCredential / UploadInitRequest /
|
||||
/// UploadInitResponse / UploadCompleteResponse / UploadSessionRecord /
|
||||
/// UploadSessionPhoto / UploadSessionState;
|
||||
/// - services/api/test/uploads.e2e-spec.ts 的凭证形状断言
|
||||
/// (mode=presigned、method=PUT、headers={'Content-Type':'image/jpeg'}、
|
||||
/// credential.expiresAt > now)与 staging objectKey 形态
|
||||
/// (photo-live/{env}/staging/{uploadId}/{photoId}.{ext})。
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/features/photos/models.dart';
|
||||
import 'package:photo_live/features/uploads/models.dart';
|
||||
|
||||
final Map<String, dynamic> credentialFixture = <String, dynamic>{
|
||||
'mode': 'presigned',
|
||||
'url':
|
||||
'http://127.0.0.1:9000/photo-live-dev/staging/upl0000001/pho0000001.jpg'
|
||||
'?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600',
|
||||
'method': 'PUT',
|
||||
'headers': {'Content-Type': 'image/jpeg'},
|
||||
'expiresAt': '2026-09-19T13:00:00.000Z',
|
||||
};
|
||||
|
||||
final Map<String, dynamic> initResponseFixture = <String, dynamic>{
|
||||
'uploadId': 'upl0000001',
|
||||
'photoId': 'pho0000001',
|
||||
'objectKey': 'photo-live/dev/staging/upl0000001/pho0000001.jpg',
|
||||
'credential': credentialFixture,
|
||||
};
|
||||
|
||||
void main() {
|
||||
test('UploadCredential fromJson/toJson 往返(e2e 凭证形状)', () {
|
||||
final model = UploadCredential.fromJson(credentialFixture);
|
||||
expect(model.mode, 'presigned');
|
||||
expect(model.method, 'PUT');
|
||||
expect(model.headers['Content-Type'], 'image/jpeg');
|
||||
expect(model.expiresAt.isAfter(DateTime.utc(2026, 9, 19)), isTrue);
|
||||
expect(model.toJson(), equals(credentialFixture));
|
||||
});
|
||||
|
||||
test('UploadInitResponse fromJson/toJson 往返', () {
|
||||
final model = UploadInitResponse.fromJson(initResponseFixture);
|
||||
expect(model.uploadId, 'upl0000001');
|
||||
expect(model.photoId, 'pho0000001');
|
||||
expect(model.objectKey, 'photo-live/dev/staging/upl0000001/pho0000001.jpg');
|
||||
expect(model.toJson(), equals(initResponseFixture));
|
||||
});
|
||||
|
||||
test('UploadInitRequest toJson:categoryId 缺省不下发', () {
|
||||
const req = UploadInitRequest(
|
||||
albumId: 'cktragt000001',
|
||||
clientTaskId: 'task-uuid-0001',
|
||||
filename: 'IMG_20260919_0001.jpg',
|
||||
bytes: 4096000,
|
||||
contentType: 'image/jpeg',
|
||||
);
|
||||
expect(req.toJson(), {
|
||||
'albumId': 'cktragt000001',
|
||||
'clientTaskId': 'task-uuid-0001',
|
||||
'filename': 'IMG_20260919_0001.jpg',
|
||||
'bytes': 4096000,
|
||||
'contentType': 'image/jpeg',
|
||||
});
|
||||
|
||||
const withCategory = UploadInitRequest(
|
||||
albumId: 'cktragt000001',
|
||||
categoryId: 'ckucat0000001',
|
||||
clientTaskId: 'task-uuid-0002',
|
||||
filename: 'IMG_20260919_0002.png',
|
||||
bytes: 8192000,
|
||||
contentType: 'image/png',
|
||||
);
|
||||
expect(withCategory.toJson()['categoryId'], 'ckucat0000001');
|
||||
});
|
||||
|
||||
test('UploadCompleteResponse fromJson/toJson 往返', () {
|
||||
final fixture = <String, dynamic>{
|
||||
'photoId': 'pho0000001',
|
||||
'processingState': 'processing',
|
||||
};
|
||||
final model = UploadCompleteResponse.fromJson(fixture);
|
||||
expect(model.processingState, PhotoProcessingState.processing);
|
||||
expect(model.toJson(), equals(fixture));
|
||||
});
|
||||
|
||||
test('UploadSessionRecord fromJson/toJson 往返(photo 内嵌)', () {
|
||||
final fixture = <String, dynamic>{
|
||||
'uploadId': 'upl0000002',
|
||||
'albumId': 'cktragt000001',
|
||||
'clientTaskId': 'task-uuid-0002',
|
||||
'state': 'confirmed',
|
||||
'expectedBytes': 8192000,
|
||||
'contentType': 'image/png',
|
||||
'createdAt': '2026-09-19T09:15:30.000Z',
|
||||
'photo': {
|
||||
'photoId': 'pho0000002',
|
||||
'processingState': 'uploaded',
|
||||
'visibility': 'unpublished',
|
||||
},
|
||||
};
|
||||
final model = UploadSessionRecord.fromJson(fixture);
|
||||
expect(model.state, UploadSessionState.confirmed);
|
||||
expect(model.photo.processingState, PhotoProcessingState.uploaded);
|
||||
expect(model.photo.visibility, PhotoVisibility.unpublished);
|
||||
expect(model.toJson(), equals(fixture));
|
||||
});
|
||||
|
||||
test('UploadSessionState 全枚举解析(pending/confirmed/cancelled/expired)',
|
||||
() {
|
||||
for (final value in const ['pending', 'confirmed', 'cancelled', 'expired']) {
|
||||
expect(UploadSessionState.fromJson(value).wire, value);
|
||||
}
|
||||
expect(() => UploadSessionState.fromJson('failed'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('未知凭证/状态字段值 fail-fast', () {
|
||||
expect(
|
||||
() => UploadCredential.fromJson({...credentialFixture, 'mode': 'sts'}),
|
||||
returnsNormally, // mode 为 String 透传(P1a 仅 presigned,预留扩展)
|
||||
);
|
||||
expect(
|
||||
() => UploadSessionRecord.fromJson({
|
||||
'uploadId': 'u',
|
||||
'albumId': 'a',
|
||||
'clientTaskId': 'c',
|
||||
'state': 'failed', // 契约无 failed 状态(核验失败是请求级错误)
|
||||
'expectedBytes': 1,
|
||||
'contentType': 'image/jpeg',
|
||||
'createdAt': '2026-09-19T09:15:30.000Z',
|
||||
'photo': {
|
||||
'photoId': 'p',
|
||||
'processingState': 'uploaded',
|
||||
'visibility': 'unpublished',
|
||||
},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user