新增:媒体上传客户端——MediaUploader 编排状态机 + 预签名直传 + 进度组件,修正 media 端点误挂 community 服务(T3-13)
CI / flutter-gates (push) Successful in 2m30s
CI / flutter-gates (push) Successful in 2m30s
- MediaUploader:选图→压缩(80→60 降质阶梯,10 MiB 超限拒绝)→createUpload →预签名 PUT 直传(进度回调)→confirm→ready assetId 交付;并发槽位 2、 顺序即 position、单图失败不拖垮整批;凭据过期预检与存储侧 403 各自动 换新凭据一次;失败重试复用压缩产物、从 createUpload 全新开始 - 孤儿防护:未 confirm 的 assetId 只以管线局部变量存在,快照 assetId 与 ready 态断言绑定,buildAttachRequests 非全员 ready 即抛 StateError - 线路修正:media 两步上传端点由 user 服务(:8082 MediaController)提供, T3-12 误挂 community 客户端(:8084 无 media 路由),补 mediaApi 分端口 直连,compose 真链路实测证实 - UploadProgressOverlay 四态进度层(05 号规范 §3.3);直传层本地 HttpServer 三线路测试(200/403/断连);compose 冒烟测试 (PATBOND_MEDIA_SMOKE=1 启用,默认跳过) - 新依赖:image_picker、flutter_image_compress(03 号评估选型) - flutter test 379 全绿(基线 347,+32)、analyze 0、format 无 diff Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:patbond_flutter/features/community/media_direct_upload.dart';
|
||||
|
||||
/// 预签名直传客户端(T3-13):本地 HttpServer 模拟 MinIO
|
||||
/// (埋点队列测试先例)——200 成功、403 签名过期、传输中断三线路。
|
||||
void main() {
|
||||
Future<HttpServer> startServer(
|
||||
Future<void> Function(HttpRequest request) handler,
|
||||
) async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
server.listen((request) async => handler(request));
|
||||
return server;
|
||||
}
|
||||
|
||||
test('200:PUT 本体逐字节到达,requiredHeaders 原样携带,无鉴权头泄漏', () async {
|
||||
late Map<String, String?> seenHeaders;
|
||||
late List<int> seenBody;
|
||||
late String seenMethod;
|
||||
final server = await startServer((request) async {
|
||||
seenMethod = request.method;
|
||||
seenHeaders = {
|
||||
'content-type': request.headers.value('content-type'),
|
||||
'authorization': request.headers.value('authorization'),
|
||||
'x-device-id': request.headers.value('x-device-id'),
|
||||
};
|
||||
seenBody = await request.fold<List<int>>(
|
||||
[],
|
||||
(all, chunk) => all..addAll(chunk),
|
||||
);
|
||||
request.response.statusCode = 200;
|
||||
await request.response.close();
|
||||
});
|
||||
addTearDown(() => server.close(force: true));
|
||||
|
||||
final bytes = Uint8List.fromList(List.generate(4096, (i) => i % 251));
|
||||
final progress = <(int, int)>[];
|
||||
await DioMediaDirectUploadClient().put(
|
||||
url:
|
||||
'http://127.0.0.1:${server.port}/patbond-media/post_image/a-1?X-Amz-Signature=sig',
|
||||
headers: const {'Content-Type': 'image/jpeg'},
|
||||
bytes: bytes,
|
||||
onProgress: (sent, total) => progress.add((sent, total)),
|
||||
);
|
||||
|
||||
expect(seenMethod, 'PUT');
|
||||
expect(seenBody, bytes);
|
||||
// Content-Type 已签进签名,必须原样携带。
|
||||
expect(seenHeaders['content-type'], 'image/jpeg');
|
||||
// 裸客户端:预签名 URL 即鉴权,业务侧 Bearer/设备头不得外漏给存储。
|
||||
expect(seenHeaders['authorization'], isNull);
|
||||
expect(seenHeaders['x-device-id'], isNull);
|
||||
expect(progress.last.$1, progress.last.$2);
|
||||
expect(progress.last.$2, bytes.length);
|
||||
});
|
||||
|
||||
test('403(签名过期/被改动)→ isCredentialRejected 异常', () async {
|
||||
final server = await startServer((request) async {
|
||||
await request.drain<void>();
|
||||
request.response.statusCode = 403;
|
||||
await request.response.close();
|
||||
});
|
||||
addTearDown(() => server.close(force: true));
|
||||
|
||||
await expectLater(
|
||||
DioMediaDirectUploadClient().put(
|
||||
url: 'http://127.0.0.1:${server.port}/patbond-media/a-1',
|
||||
headers: const {'Content-Type': 'image/jpeg'},
|
||||
bytes: Uint8List(16),
|
||||
),
|
||||
throwsA(
|
||||
isA<MediaDirectUploadException>()
|
||||
.having((e) => e.statusCode, 'statusCode', 403)
|
||||
.having(
|
||||
(e) => e.isCredentialRejected,
|
||||
'isCredentialRejected',
|
||||
true,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('传输中断(服务端半途断连)→ 网络型异常(statusCode null,可重试)', () async {
|
||||
final server = await startServer((request) async {
|
||||
// 读取少量字节后直接断开 socket,不回任何响应。
|
||||
await request.take(1).drain<void>();
|
||||
final socket = await request.response.detachSocket(writeHeaders: false);
|
||||
await socket.close();
|
||||
socket.destroy();
|
||||
});
|
||||
addTearDown(() => server.close(force: true));
|
||||
|
||||
await expectLater(
|
||||
DioMediaDirectUploadClient().put(
|
||||
url: 'http://127.0.0.1:${server.port}/patbond-media/a-1',
|
||||
headers: const {'Content-Type': 'image/jpeg'},
|
||||
bytes: Uint8List.fromList(List.filled(1 << 20, 7)),
|
||||
),
|
||||
throwsA(
|
||||
isA<MediaDirectUploadException>()
|
||||
.having((e) => e.statusCode, 'statusCode', isNull)
|
||||
.having(
|
||||
(e) => e.isCredentialRejected,
|
||||
'isCredentialRejected',
|
||||
false,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user