/// 上传域模型往返测试(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 credentialFixture = { '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 initResponseFixture = { 'uploadId': 'upl0000001', 'photoId': 'pho0000001', 'objectKey': 'photo-live/dev/staging/upl0000001/pho0000001.jpg', 'credential': credentialFixture, }; /// UploadSessionRecord 最小形状(sourceDeviceId 测试基底)。 const Map _recordFixture = { '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', }, }; 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('UploadInitRequest sourceDeviceId(P3 Task 3 契约增补):' '提供时下发、缺省不下发', () { const withDevice = UploadInitRequest( albumId: 'cktragt000001', clientTaskId: 'task-uuid-0003', filename: 'IMG_20260919_0003.jpg', bytes: 1024, contentType: 'image/jpeg', sourceDeviceId: '0123456789abcdef0123456789abcdef', ); expect(withDevice.toJson()['sourceDeviceId'], isNotNull); const withoutDevice = UploadInitRequest( albumId: 'cktragt000001', clientTaskId: 'task-uuid-0004', filename: 'IMG_20260919_0004.jpg', bytes: 1024, contentType: 'image/jpeg', ); expect(withoutDevice.toJson().containsKey('sourceDeviceId'), isFalse); }); test('UploadCompleteResponse fromJson/toJson 往返', () { final fixture = { '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 = { '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('UploadSessionRecord sourceDeviceId:可空回显(A19 设备来源)', () { final withDevice = UploadSessionRecord.fromJson({ ..._recordFixture, 'sourceDeviceId': '0123456789abcdef0123456789abcdef', }); expect(withDevice.sourceDeviceId, '0123456789abcdef0123456789abcdef'); expect(withDevice.toJson()['sourceDeviceId'], isNotNull); // init 未携带时为 null(契约 type: [string, 'null'])。 final withoutDevice = UploadSessionRecord.fromJson(_recordFixture); expect(withoutDevice.sourceDeviceId, isNull); expect(withoutDevice.toJson().containsKey('sourceDeviceId'), isFalse); }); 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, ); }); }