f975321b67
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>
143 lines
5.1 KiB
Dart
143 lines
5.1 KiB
Dart
/// 上传域模型往返测试(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,
|
||
);
|
||
});
|
||
}
|