Files
photo-live/apps/mobile/test/features/uploads_models_test.dart
T
浅念 f975321b67 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>
2026-09-19 00:40:02 +08:00

143 lines
5.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 上传域模型往返测试(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,
);
});
}