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,512 @@
|
||||
/// ApiClient 拦截器与端点行为测试(P3 计划 Task 2)。
|
||||
///
|
||||
/// 网络层经手写 FakeAdapter(dio HttpClientAdapter)打桩——响应体为
|
||||
/// 依 openapi.yaml 各端点 schema 手工转写的 JSON;未引入
|
||||
/// http_mock_adapter(零额外 dev 依赖,打桩面完全可控)。
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/core/app_exception.dart';
|
||||
import 'package:photo_live/core/error_codes.dart';
|
||||
import 'package:photo_live/core/models/cursor_page.dart';
|
||||
import 'package:photo_live/core/session_store.dart';
|
||||
import 'package:photo_live/features/albums/models.dart';
|
||||
import 'package:photo_live/features/auth/models.dart';
|
||||
import 'package:photo_live/features/photos/models.dart';
|
||||
|
||||
/// 内存会话存储(测试替身)。
|
||||
class FakeSessionStore implements SessionStore {
|
||||
Session? session;
|
||||
|
||||
@override
|
||||
Future<Session?> read() async => session;
|
||||
|
||||
@override
|
||||
Future<void> save(Session value) async => session = value;
|
||||
|
||||
@override
|
||||
Future<void> clear() async => session = null;
|
||||
}
|
||||
|
||||
/// 记录到适配器的请求快照。
|
||||
class RecordedRequest {
|
||||
const RecordedRequest({
|
||||
required this.method,
|
||||
required this.path,
|
||||
required this.query,
|
||||
required this.headers,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
final String method;
|
||||
final String path;
|
||||
final Map<String, dynamic> query;
|
||||
final Map<String, dynamic> headers;
|
||||
final Object? body;
|
||||
}
|
||||
|
||||
/// 手写 dio 打桩适配器:按路由分发预设响应并记录全部请求。
|
||||
class FakeAdapter implements HttpClientAdapter {
|
||||
FakeAdapter(this.router);
|
||||
|
||||
/// 路由器:返回 (status, body);body 为 null 表示无响应体(204)。
|
||||
final (int, Object?) Function(RequestOptions options) router;
|
||||
|
||||
final List<RecordedRequest> requests = <RecordedRequest>[];
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream? requestStream,
|
||||
Future? cancelFuture,
|
||||
) async {
|
||||
requests.add(
|
||||
RecordedRequest(
|
||||
method: options.method,
|
||||
path: options.path,
|
||||
query: Map<String, dynamic>.from(options.queryParameters),
|
||||
headers: Map<String, dynamic>.from(options.headers),
|
||||
body: options.data,
|
||||
),
|
||||
);
|
||||
final (status, body) = router(options);
|
||||
if (body == null) {
|
||||
// 204 等:空体且不带 JSON content-type(transformer 不解码)。
|
||||
return ResponseBody.fromString('', status);
|
||||
}
|
||||
return ResponseBody.fromString(
|
||||
jsonEncode(body),
|
||||
status,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
/// 捕获异步错误(不冒泡),返回异常对象;正常完成返回 null。
|
||||
Future<Object?> captureError(Future<void> Function() run) async {
|
||||
try {
|
||||
await run();
|
||||
return null;
|
||||
} on Object catch (e) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
/// openapi.yaml components.schemas.AlbumSummary 形状的最小相册体。
|
||||
Map<String, dynamic> albumBody() => <String, dynamic>{
|
||||
'id': 'alb-1',
|
||||
'publicToken': 'a1b2c3d4e5f6g7h8i9j0',
|
||||
'ownerId': 'usr-1',
|
||||
'status': 'active',
|
||||
'defaultLocale': 'zh-CN',
|
||||
'timezone': 'Asia/Shanghai',
|
||||
'startAt': '2026-10-01T02:00:00Z',
|
||||
'endAt': null,
|
||||
'uploadEnabled': true,
|
||||
'publicEnabled': true,
|
||||
'downloadEnabled': true,
|
||||
'originalDownloadEnabled': false,
|
||||
'watermarkVersion': 0,
|
||||
'translations': [
|
||||
{
|
||||
'locale': 'zh-CN',
|
||||
'title': '测试相册',
|
||||
'description': null,
|
||||
'venue': null,
|
||||
'address': null,
|
||||
},
|
||||
],
|
||||
'createdAt': '2026-09-10T08:00:00Z',
|
||||
'updatedAt': '2026-09-10T08:00:00Z',
|
||||
'statsSyncedAt': null,
|
||||
'categories': [],
|
||||
};
|
||||
|
||||
ApiClient _client(FakeSessionStore store, FakeAdapter adapter) {
|
||||
final dio = Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter;
|
||||
return ApiClient(sessionStore: store, dio: dio);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore store;
|
||||
late int unauthorizedCalls;
|
||||
|
||||
setUp(() {
|
||||
store = FakeSessionStore();
|
||||
unauthorizedCalls = 0;
|
||||
});
|
||||
|
||||
void seedSession() {
|
||||
store.session = Session(
|
||||
token: 'tok-live-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'staff01',
|
||||
);
|
||||
}
|
||||
|
||||
ApiClient wired(FakeAdapter adapter) => _client(store, adapter)
|
||||
..onUnauthorized = () => unauthorizedCalls++;
|
||||
|
||||
test('会话存在时请求注入 Authorization: Bearer', () async {
|
||||
seedSession();
|
||||
final adapter = FakeAdapter((_) => (
|
||||
200,
|
||||
{
|
||||
'items': <String>[],
|
||||
'nextCursor': null,
|
||||
'hasMore': false,
|
||||
}
|
||||
));
|
||||
|
||||
await wired(adapter).listAlbums();
|
||||
|
||||
expect(adapter.requests.single.path, '/v1/albums');
|
||||
expect(
|
||||
adapter.requests.single.headers['Authorization'],
|
||||
'Bearer tok-live-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('无会话时不注入 Authorization(裸发,由服务端 401 裁决)', () async {
|
||||
final adapter = FakeAdapter((_) => (
|
||||
200,
|
||||
{
|
||||
'items': <String>[],
|
||||
'nextCursor': null,
|
||||
'hasMore': false,
|
||||
}
|
||||
));
|
||||
|
||||
await wired(adapter).listAlbums();
|
||||
|
||||
expect(adapter.requests.single.headers.containsKey('Authorization'),
|
||||
isFalse);
|
||||
});
|
||||
|
||||
test('login:不携带令牌、回写会话(token+expiresAt+account)', () async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/auth/password'
|
||||
? (
|
||||
200,
|
||||
{
|
||||
'sessionToken': 'tok-new',
|
||||
'expiresAt': '2026-09-20T12:00:00Z',
|
||||
}
|
||||
)
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
|
||||
final resp = await wired(adapter).login('staff01', 'pw-123');
|
||||
|
||||
expect(resp, isA<LoginResponse>());
|
||||
expect(resp.sessionToken, 'tok-new');
|
||||
final req = adapter.requests.single;
|
||||
expect(req.method, 'POST');
|
||||
expect(req.headers.containsKey('Authorization'), isFalse);
|
||||
expect(req.body, {'account': 'staff01', 'password': 'pw-123'});
|
||||
expect(store.session?.token, 'tok-new');
|
||||
expect(store.session?.account, 'staff01');
|
||||
expect(store.session?.expiresAt, DateTime.utc(2026, 9, 20, 12));
|
||||
});
|
||||
|
||||
test('业务 401:清空会话 + 通知一次 + 抛 AUTH_TOKEN_EXPIRED', () async {
|
||||
seedSession();
|
||||
final adapter = FakeAdapter((_) => (
|
||||
401,
|
||||
{
|
||||
'code': 'AUTH_TOKEN_EXPIRED',
|
||||
'message': 'token expired',
|
||||
'requestId': 'req-401',
|
||||
}
|
||||
));
|
||||
|
||||
final err = await captureError(() => wired(adapter).listAlbums());
|
||||
|
||||
expect(
|
||||
err,
|
||||
isA<AppException>()
|
||||
.having((e) => e.code, 'code', ErrorCodes.authTokenExpired)
|
||||
.having((e) => e.requestId, 'requestId', 'req-401'),
|
||||
);
|
||||
expect(store.session, isNull, reason: '401 必须清空本地会话');
|
||||
expect(unauthorizedCalls, 1, reason: '驱逐通知恰好一次');
|
||||
});
|
||||
|
||||
test('并发 401:通知去重(新会话写入前只通知一次)', () async {
|
||||
seedSession();
|
||||
final adapter = FakeAdapter((_) => (
|
||||
401,
|
||||
{'code': 'AUTH_TOKEN_EXPIRED', 'message': 'x', 'requestId': 'r'},
|
||||
));
|
||||
final client = wired(adapter);
|
||||
|
||||
final results = await Future.wait([
|
||||
captureError(() => client.listAlbums()),
|
||||
captureError(() => client.listUploads()),
|
||||
captureError(() => client.getAlbum('alb-1')),
|
||||
]);
|
||||
expect(results.every((e) => e is AppException), isTrue);
|
||||
expect(store.session, isNull);
|
||||
expect(unauthorizedCalls, 1);
|
||||
});
|
||||
|
||||
test('登录端点 401(凭据错误):不驱逐、不通知、不附加旧令牌', () async {
|
||||
seedSession();
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/auth/password'
|
||||
? (
|
||||
401,
|
||||
{
|
||||
'code': 'AUTH_INVALID_CREDENTIALS',
|
||||
'message': 'bad credentials',
|
||||
'requestId': 'req-1',
|
||||
}
|
||||
)
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
|
||||
final err =
|
||||
await captureError(() => wired(adapter).login('staff01', 'wrong-pw'));
|
||||
|
||||
expect(
|
||||
err,
|
||||
isA<AppException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
ErrorCodes.authInvalidCredentials,
|
||||
),
|
||||
);
|
||||
expect(store.session?.token, 'tok-live-1', reason: '登录失败不清会话');
|
||||
expect(unauthorizedCalls, 0);
|
||||
expect(adapter.requests.single.headers.containsKey('Authorization'),
|
||||
isFalse);
|
||||
});
|
||||
|
||||
test('新会话写入后,后续 401 再次通知(标志复位)', () async {
|
||||
seedSession();
|
||||
var tokenDead = true;
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path == '/v1/auth/password') {
|
||||
return (
|
||||
200,
|
||||
{'sessionToken': 'tok-2', 'expiresAt': '2026-09-21T00:00:00Z'}
|
||||
);
|
||||
}
|
||||
return tokenDead
|
||||
? (
|
||||
401,
|
||||
{'code': 'AUTH_TOKEN_EXPIRED', 'message': 'x', 'requestId': 'r'}
|
||||
)
|
||||
: (
|
||||
200,
|
||||
{
|
||||
'items': <String>[],
|
||||
'nextCursor': null,
|
||||
'hasMore': false,
|
||||
}
|
||||
);
|
||||
});
|
||||
final client = wired(adapter);
|
||||
|
||||
await captureError(() => client.listAlbums());
|
||||
expect(unauthorizedCalls, 1);
|
||||
await client.login('staff01', 'pw');
|
||||
tokenDead = false;
|
||||
await client.listAlbums(); // 新会话下正常
|
||||
expect(unauthorizedCalls, 1);
|
||||
|
||||
tokenDead = true;
|
||||
await captureError(() => client.listAlbums());
|
||||
expect(unauthorizedCalls, 2, reason: '重新登录后 401 应再次通知');
|
||||
});
|
||||
|
||||
test('错误体经端点方法映射为 AppException(getAlbum 404)', () async {
|
||||
final adapter = FakeAdapter((_) => (
|
||||
404,
|
||||
{
|
||||
'code': 'ALBUM_NOT_FOUND',
|
||||
'message': 'album missing',
|
||||
'requestId': 'req-404',
|
||||
}
|
||||
));
|
||||
|
||||
final err = await captureError(() => wired(adapter).getAlbum('ghost'));
|
||||
|
||||
expect(
|
||||
err,
|
||||
isA<AppException>()
|
||||
.having((e) => e.code, 'code', ErrorCodes.albumNotFound)
|
||||
.having((e) => e.statusCode, 'statusCode', 404)
|
||||
.having((e) => e.requestId, 'requestId', 'req-404'),
|
||||
);
|
||||
});
|
||||
|
||||
test('listAlbums:游标/条数透传 + 分页字段解析', () async {
|
||||
final adapter = FakeAdapter((_) => (
|
||||
200,
|
||||
{
|
||||
'items': [albumBody()],
|
||||
'nextCursor': 'cur-2',
|
||||
'hasMore': true,
|
||||
}
|
||||
));
|
||||
|
||||
final page = await wired(adapter).listAlbums(cursor: 'cur-1', limit: 50);
|
||||
|
||||
expect(
|
||||
adapter.requests.single.query,
|
||||
containsPair('cursor', 'cur-1'),
|
||||
);
|
||||
expect(adapter.requests.single.query, containsPair('limit', 50));
|
||||
expect(page, isA<CursorPage<AlbumSummary>>());
|
||||
expect(page.items.single.id, 'alb-1');
|
||||
expect(page.nextCursor, 'cur-2');
|
||||
expect(page.hasMore, isTrue);
|
||||
});
|
||||
|
||||
test('listAlbumPhotos:可见性/分类过滤参数透传', () async {
|
||||
final adapter = FakeAdapter((_) => (
|
||||
200,
|
||||
{
|
||||
'items': <String>[],
|
||||
'nextCursor': null,
|
||||
'hasMore': false,
|
||||
}
|
||||
));
|
||||
|
||||
await wired(adapter).listAlbumPhotos(
|
||||
'alb-1',
|
||||
visibility: PhotoVisibility.hidden,
|
||||
categoryId: 'cat-9',
|
||||
);
|
||||
|
||||
final query = adapter.requests.single.query;
|
||||
expect(query, containsPair('visibility', 'hidden'));
|
||||
expect(query, containsPair('categoryId', 'cat-9'));
|
||||
expect(query.containsKey('cursor'), isFalse);
|
||||
expect(query.containsKey('limit'), isFalse);
|
||||
});
|
||||
|
||||
test('createAlbum:请求体透传 + 响应解析为 Album', () async {
|
||||
final adapter = FakeAdapter((_) => (201, albumBody()));
|
||||
|
||||
final album = await wired(adapter).createAlbum(
|
||||
AlbumCreateRequest(
|
||||
translations: const [
|
||||
AlbumTranslation(locale: 'zh-CN', title: '测试相册'),
|
||||
],
|
||||
defaultLocale: 'zh-CN',
|
||||
startAt: DateTime.utc(2026, 10, 1, 2),
|
||||
),
|
||||
);
|
||||
|
||||
expect(adapter.requests.single.method, 'POST');
|
||||
expect(adapter.requests.single.body, isA<Map<String, dynamic>>());
|
||||
expect(album, isA<Album>());
|
||||
expect(album.id, 'alb-1');
|
||||
expect(album.translations.single.title, '测试相册');
|
||||
});
|
||||
|
||||
test('updatePhotoVisibility:非法目标本地即拒绝(不走网络)', () async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
final client = wired(adapter);
|
||||
|
||||
expect(
|
||||
() => client.updatePhotoVisibility('pho-1', PhotoVisibility.unpublished),
|
||||
throwsArgumentError,
|
||||
);
|
||||
expect(
|
||||
() => client.updatePhotoVisibility('pho-1', PhotoVisibility.deleted),
|
||||
throwsArgumentError,
|
||||
);
|
||||
expect(adapter.requests, isEmpty);
|
||||
});
|
||||
|
||||
test('updatePhotoVisibility:合法目标 PATCH 正确体', () async {
|
||||
final adapter = FakeAdapter((_) => (200, {
|
||||
'photoId': 'pho-1',
|
||||
'albumId': 'alb-1',
|
||||
'categoryId': null,
|
||||
'uploaderId': 'usr-1',
|
||||
'visibility': 'hidden',
|
||||
'processingState': 'ready',
|
||||
'bytes': 1,
|
||||
'width': null,
|
||||
'height': null,
|
||||
'shotAt': null,
|
||||
'uploadedAt': '2026-09-12T08:05:00Z',
|
||||
'publishedAt': null,
|
||||
'likeCount': 0,
|
||||
}));
|
||||
|
||||
final photo = await wired(adapter)
|
||||
.updatePhotoVisibility('pho-1', PhotoVisibility.hidden);
|
||||
|
||||
expect(adapter.requests.single.method, 'PATCH');
|
||||
expect(adapter.requests.single.path, '/v1/photos/pho-1');
|
||||
expect(adapter.requests.single.body, {'visibility': 'hidden'});
|
||||
expect(photo.visibility, PhotoVisibility.hidden);
|
||||
expect(photo, isA<PhotoRecord>());
|
||||
});
|
||||
|
||||
test('refresh:令牌/账号保留,仅滑动更新过期时间', () async {
|
||||
seedSession();
|
||||
final adapter = FakeAdapter(
|
||||
(o) => (
|
||||
200,
|
||||
{'expiresAt': '2026-10-01T00:00:00Z'},
|
||||
),
|
||||
);
|
||||
|
||||
final resp = await wired(adapter).refresh();
|
||||
|
||||
expect(adapter.requests.single.path, '/v1/auth/refresh');
|
||||
expect(
|
||||
adapter.requests.single.headers['Authorization'],
|
||||
'Bearer tok-live-1',
|
||||
);
|
||||
expect(resp.expiresAt, DateTime.utc(2026, 10, 1));
|
||||
expect(store.session?.token, 'tok-live-1', reason: '续期不换令牌');
|
||||
expect(store.session?.account, 'staff01');
|
||||
expect(store.session?.expiresAt, DateTime.utc(2026, 10, 1));
|
||||
});
|
||||
|
||||
test('logout:服务端 500 也照样清除本地会话且不抛错(尽力而为)',
|
||||
() async {
|
||||
seedSession();
|
||||
final adapter = FakeAdapter((_) => (
|
||||
500,
|
||||
{'code': 'INTERNAL_ERROR', 'message': 'boom', 'requestId': 'r'},
|
||||
));
|
||||
|
||||
await wired(adapter).logout();
|
||||
|
||||
expect(adapter.requests.single.path, '/v1/auth/logout');
|
||||
expect(adapter.requests.single.method, 'POST');
|
||||
expect(store.session, isNull);
|
||||
expect(unauthorizedCalls, 0, reason: 'logout 不触发驱逐通知');
|
||||
});
|
||||
|
||||
test('removeMember / deletePhoto:DELETE 且容忍 204 空响应体', () async {
|
||||
final adapter = FakeAdapter((_) => (204, null));
|
||||
final client = wired(adapter);
|
||||
|
||||
await client.removeMember('alb-1', 'usr-2');
|
||||
await client.deletePhoto('pho-1');
|
||||
|
||||
expect(adapter.requests[0].method, 'DELETE');
|
||||
expect(adapter.requests[0].path, '/v1/albums/alb-1/members/usr-2');
|
||||
expect(adapter.requests[1].method, 'DELETE');
|
||||
expect(adapter.requests[1].path, '/v1/photos/pho-1');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/// CursorPage 游标解析边界用例(P3 计划 Task 2)。
|
||||
///
|
||||
/// 契约来源:openapi.yaml components.schemas.CursorPageMeta——
|
||||
/// 「hasMore=true 当且仅当 nextCursor≠null,两者必须成对填充」。
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/models/cursor_page.dart';
|
||||
|
||||
/// 最小条目模型(仅用于分页解析验证)。
|
||||
Map<String, dynamic> _item(String id) => {'id': id};
|
||||
|
||||
String _parseItem(Map<String, dynamic> json) => json['id'] as String;
|
||||
|
||||
void main() {
|
||||
test('常规页:hasMore=true 与 nextCursor 成对保留', () {
|
||||
final page = CursorPage.parse<String>(
|
||||
{
|
||||
'items': [_item('a'), _item('b')],
|
||||
'nextCursor': 'cur-2',
|
||||
'hasMore': true,
|
||||
},
|
||||
_parseItem,
|
||||
);
|
||||
expect(page.items, ['a', 'b']);
|
||||
expect(page.nextCursor, 'cur-2');
|
||||
expect(page.hasMore, isTrue);
|
||||
});
|
||||
|
||||
test('末页:nextCursor=null 且 hasMore=false', () {
|
||||
final page = CursorPage.parse<String>(
|
||||
{
|
||||
'items': [_item('c')],
|
||||
'nextCursor': null,
|
||||
'hasMore': false,
|
||||
},
|
||||
_parseItem,
|
||||
);
|
||||
expect(page.items, ['c']);
|
||||
expect(page.nextCursor, isNull);
|
||||
expect(page.hasMore, isFalse);
|
||||
});
|
||||
|
||||
test('违约容错:hasMore=true 但游标缺失 → 按末页处理(防死循环)', () {
|
||||
final page = CursorPage.parse<String>(
|
||||
{
|
||||
'items': [_item('a')],
|
||||
'nextCursor': null,
|
||||
'hasMore': true, // 违反 CursorPageMeta 不变式
|
||||
},
|
||||
_parseItem,
|
||||
);
|
||||
expect(page.nextCursor, isNull);
|
||||
expect(page.hasMore, isFalse);
|
||||
});
|
||||
|
||||
test('违约容错:hasMore=false 但携带游标 → 游标丢弃', () {
|
||||
final page = CursorPage.parse<String>(
|
||||
{
|
||||
'items': <Map<String, dynamic>>[],
|
||||
'nextCursor': 'stale',
|
||||
'hasMore': false,
|
||||
},
|
||||
_parseItem,
|
||||
);
|
||||
expect(page.nextCursor, isNull);
|
||||
expect(page.hasMore, isFalse);
|
||||
expect(page.items, isEmpty);
|
||||
});
|
||||
|
||||
test('空字符串游标视为 null', () {
|
||||
final page = CursorPage.parse<String>(
|
||||
{
|
||||
'items': <Map<String, dynamic>>[],
|
||||
'nextCursor': '',
|
||||
'hasMore': true,
|
||||
},
|
||||
_parseItem,
|
||||
);
|
||||
expect(page.nextCursor, isNull);
|
||||
expect(page.hasMore, isFalse);
|
||||
});
|
||||
|
||||
test('缺省字段:无 hasMore 默认 false,无 items 默认空数组', () {
|
||||
final page = CursorPage.parse<String>(<String, dynamic>{}, _parseItem);
|
||||
expect(page.items, isEmpty);
|
||||
expect(page.nextCursor, isNull);
|
||||
expect(page.hasMore, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/// 错误码映射表驱动测试(P3 计划 Task 2)。
|
||||
///
|
||||
/// 码表来源:packages/contracts/src/errors.ts(17 项,1:1 镜像到
|
||||
/// lib/core/error_codes.dart)与 openapi.yaml
|
||||
/// components.schemas.Error 的 enum;状态码配对取 openapi.yaml 各端点
|
||||
/// responses 标注(如 429 AUTH_RATE_LIMITED)。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/core/app_exception.dart';
|
||||
import 'package:photo_live/core/error_codes.dart';
|
||||
|
||||
/// 构造「服务端错误体」型 DioException(badResponse)。
|
||||
DioException _httpError(int status, Map<String, dynamic> body) {
|
||||
final options = RequestOptions(path: '/v1/albums');
|
||||
return DioException(
|
||||
type: DioExceptionType.badResponse,
|
||||
requestOptions: options,
|
||||
response: Response(
|
||||
requestOptions: options,
|
||||
statusCode: status,
|
||||
data: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构造「无契约错误体」型 DioException。
|
||||
DioException _bareError(int status) {
|
||||
final options = RequestOptions(path: '/v1/albums');
|
||||
return DioException(
|
||||
type: DioExceptionType.badResponse,
|
||||
requestOptions: options,
|
||||
response: Response(requestOptions: options, statusCode: status),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('mapDioException:契约 17 码全量透传(errors.ts 1:1)', () {
|
||||
// (code, 典型状态码) —— 状态码取自 openapi.yaml 各响应 description。
|
||||
const cases = <(String, int)>[
|
||||
(ErrorCodes.authInvalidCredentials, 401),
|
||||
(ErrorCodes.authTokenExpired, 401),
|
||||
(ErrorCodes.authRateLimited, 429), // Task 13 契约修订
|
||||
(ErrorCodes.rateLimited, 429), // P2 Task 1 增补(观众互动限速)
|
||||
(ErrorCodes.authForbidden, 403),
|
||||
(ErrorCodes.notFound, 404), // P1a 增补(框架级 404)
|
||||
(ErrorCodes.albumNotFound, 404),
|
||||
(ErrorCodes.albumNotPublic, 404),
|
||||
(ErrorCodes.albumUploadDisabled, 409),
|
||||
(ErrorCodes.memberConflict, 409),
|
||||
(ErrorCodes.uploadSessionConflict, 409),
|
||||
(ErrorCodes.uploadVerifyFailed, 400),
|
||||
(ErrorCodes.photoNotFound, 404),
|
||||
(ErrorCodes.photoNotPublic, 404),
|
||||
(ErrorCodes.validationFailed, 400),
|
||||
(ErrorCodes.syncAuthFailed, 401), // App 侧不消费,仅为镜像完整性
|
||||
(ErrorCodes.internalError, 500),
|
||||
];
|
||||
|
||||
test('错误体 {code,message,requestId} 逐码映射为 AppException', () {
|
||||
for (final (code, status) in cases) {
|
||||
final ex = ApiClient.mapDioException(
|
||||
_httpError(status, {
|
||||
'code': code,
|
||||
'message': 'diagnostic message',
|
||||
'requestId': 'req-001',
|
||||
}),
|
||||
);
|
||||
expect(ex.code, code, reason: '错误体 code=$code 应原样透传');
|
||||
expect(ex.message, 'diagnostic message', reason: 'code=$code');
|
||||
expect(ex.requestId, 'req-001', reason: 'code=$code');
|
||||
expect(ex.statusCode, status, reason: 'code=$code');
|
||||
expect(ex.isAuthExpired, code == ErrorCodes.authTokenExpired);
|
||||
}
|
||||
});
|
||||
|
||||
test('码表与 errors.ts 字面值逐项一致(防漂移)', () {
|
||||
// 顺序即 packages/contracts/src/errors.ts 的声明顺序。
|
||||
expect(
|
||||
[
|
||||
ErrorCodes.authInvalidCredentials,
|
||||
ErrorCodes.authTokenExpired,
|
||||
ErrorCodes.authRateLimited,
|
||||
ErrorCodes.rateLimited,
|
||||
ErrorCodes.authForbidden,
|
||||
ErrorCodes.notFound,
|
||||
ErrorCodes.albumNotFound,
|
||||
ErrorCodes.albumNotPublic,
|
||||
ErrorCodes.albumUploadDisabled,
|
||||
ErrorCodes.memberConflict,
|
||||
ErrorCodes.uploadSessionConflict,
|
||||
ErrorCodes.uploadVerifyFailed,
|
||||
ErrorCodes.photoNotFound,
|
||||
ErrorCodes.photoNotPublic,
|
||||
ErrorCodes.validationFailed,
|
||||
ErrorCodes.syncAuthFailed,
|
||||
ErrorCodes.internalError,
|
||||
],
|
||||
everyElement(isNotNull),
|
||||
);
|
||||
expect(ErrorCodes.authRateLimited, 'AUTH_RATE_LIMITED');
|
||||
expect(ErrorCodes.notFound, 'NOT_FOUND');
|
||||
expect(ErrorCodes.rateLimited, 'RATE_LIMITED');
|
||||
});
|
||||
});
|
||||
|
||||
group('mapDioException:网络层(客户端伪码)', () {
|
||||
test('连接失败 → NETWORK_ERROR', () {
|
||||
final ex = ApiClient.mapDioException(
|
||||
DioException(
|
||||
type: DioExceptionType.connectionError,
|
||||
requestOptions: RequestOptions(path: '/v1/albums'),
|
||||
),
|
||||
);
|
||||
expect(ex.code, ErrorCodes.networkError);
|
||||
expect(ex.statusCode, isNull);
|
||||
});
|
||||
|
||||
test('连接/发送/接收/转换超时 → NETWORK_TIMEOUT', () {
|
||||
const types = [
|
||||
DioExceptionType.connectionTimeout,
|
||||
DioExceptionType.sendTimeout,
|
||||
DioExceptionType.receiveTimeout,
|
||||
DioExceptionType.transformTimeout,
|
||||
];
|
||||
for (final type in types) {
|
||||
final ex = ApiClient.mapDioException(
|
||||
DioException(
|
||||
type: type,
|
||||
requestOptions: RequestOptions(path: '/v1/albums'),
|
||||
),
|
||||
);
|
||||
expect(ex.code, ErrorCodes.networkTimeout, reason: '$type');
|
||||
}
|
||||
});
|
||||
|
||||
test('取消 → CANCELLED', () {
|
||||
final ex = ApiClient.mapDioException(
|
||||
DioException(
|
||||
type: DioExceptionType.cancel,
|
||||
requestOptions: RequestOptions(path: '/v1/albums'),
|
||||
),
|
||||
);
|
||||
expect(ex.code, ErrorCodes.cancelled);
|
||||
});
|
||||
});
|
||||
|
||||
group('mapDioException:无契约错误体的兜底', () {
|
||||
// openapi.yaml info.description:状态码通用映射 401→AUTH_TOKEN_EXPIRED、
|
||||
// 500→INTERNAL_ERROR;其余无体状态归 HTTP_ERROR(statusCode 保留)。
|
||||
test('裸 401 → AUTH_TOKEN_EXPIRED', () {
|
||||
final ex = ApiClient.mapDioException(_bareError(401));
|
||||
expect(ex.code, ErrorCodes.authTokenExpired);
|
||||
expect(ex.statusCode, 401);
|
||||
});
|
||||
|
||||
test('裸 500 / 503 → INTERNAL_ERROR', () {
|
||||
expect(ApiClient.mapDioException(_bareError(500)).code,
|
||||
ErrorCodes.internalError);
|
||||
expect(ApiClient.mapDioException(_bareError(503)).code,
|
||||
ErrorCodes.internalError);
|
||||
});
|
||||
|
||||
test('裸 403 → HTTP_ERROR(保留状态码)', () {
|
||||
final ex = ApiClient.mapDioException(_bareError(403));
|
||||
expect(ex.code, ErrorCodes.httpError);
|
||||
expect(ex.statusCode, 403);
|
||||
});
|
||||
|
||||
test('错误体缺 code 字段 → 按状态码兜底', () {
|
||||
final ex = ApiClient.mapDioException(
|
||||
_httpError(400, {'message': 'no code here'}),
|
||||
);
|
||||
expect(ex.code, ErrorCodes.httpError);
|
||||
expect(ex.statusCode, 400);
|
||||
});
|
||||
|
||||
test('错误体 code 非 String → 按状态码兜底', () {
|
||||
final ex = ApiClient.mapDioException(
|
||||
_httpError(500, {'code': 42, 'message': 'numeric code'}),
|
||||
);
|
||||
expect(ex.code, ErrorCodes.internalError);
|
||||
});
|
||||
|
||||
test('requestId 缺失时为 null', () {
|
||||
final ex = ApiClient.mapDioException(
|
||||
_httpError(404, {'code': 'ALBUM_NOT_FOUND', 'message': 'x'}),
|
||||
);
|
||||
expect(ex.requestId, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('AppException.toString 含诊断要素', () {
|
||||
const ex = AppException(
|
||||
code: 'RATE_LIMITED',
|
||||
message: 'too many',
|
||||
requestId: 'req-9',
|
||||
statusCode: 429,
|
||||
);
|
||||
final s = ex.toString();
|
||||
expect(s, contains('RATE_LIMITED'));
|
||||
expect(s, contains('req-9'));
|
||||
expect(s, contains('429'));
|
||||
expect(s, contains('too many'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/// SecureSessionStore 序列化与会话语义测试(P3 计划 Task 2)。
|
||||
///
|
||||
/// 平台通道(flutter_secure_storage)调用保持最薄、不经平台通道单测;
|
||||
/// 编解码逻辑为纯静态函数在此锁定。
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/session_store.dart';
|
||||
|
||||
void main() {
|
||||
group('SecureSessionStore.encode/decode', () {
|
||||
test('全字段往返', () {
|
||||
const session = Session(
|
||||
token: 'tok-abc',
|
||||
expiresAt: null, // 以动态值覆盖,见下
|
||||
account: 'staff01',
|
||||
);
|
||||
// DateTime 非 const,单独构造完整会话。
|
||||
final full = Session(
|
||||
token: session.token,
|
||||
expiresAt: DateTime.utc(2026, 9, 19, 12, 30),
|
||||
account: session.account,
|
||||
);
|
||||
final decoded = SecureSessionStore.decode(
|
||||
SecureSessionStore.encode(full),
|
||||
);
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.token, 'tok-abc');
|
||||
expect(decoded.account, 'staff01');
|
||||
expect(decoded.expiresAt, DateTime.utc(2026, 9, 19, 12, 30));
|
||||
});
|
||||
|
||||
test('可空字段缺省不落键', () {
|
||||
const session = Session(token: 'tok-only');
|
||||
final encoded = SecureSessionStore.encode(session);
|
||||
expect(encoded.containsKey(SecureSessionStore.kTokenKey), isTrue);
|
||||
expect(
|
||||
encoded.containsKey(SecureSessionStore.kExpiresAtKey),
|
||||
isFalse,
|
||||
);
|
||||
expect(encoded.containsKey(SecureSessionStore.kAccountKey), isFalse);
|
||||
|
||||
final decoded = SecureSessionStore.decode(encoded);
|
||||
expect(decoded!.expiresAt, isNull);
|
||||
expect(decoded.account, isNull);
|
||||
});
|
||||
|
||||
test('令牌缺失/为空 → null(无会话语义)', () {
|
||||
expect(SecureSessionStore.decode(<String?, String?>{}), isNull);
|
||||
expect(
|
||||
SecureSessionStore.decode({SecureSessionStore.kTokenKey: ''}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('过期时间不可解析 → expiresAt 为 null(会话仍有效持有)', () {
|
||||
final decoded = SecureSessionStore.decode({
|
||||
SecureSessionStore.kTokenKey: 'tok-x',
|
||||
SecureSessionStore.kExpiresAtKey: 'not-a-date',
|
||||
});
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.expiresAt, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('Session.isExpired', () {
|
||||
test('过期时间为过去 → true', () {
|
||||
final s = Session(token: 't', expiresAt: DateTime.utc(2000, 1, 1));
|
||||
expect(s.isExpired, isTrue);
|
||||
});
|
||||
|
||||
test('过期时间为未来 → false', () {
|
||||
final s = Session(
|
||||
token: 't',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
);
|
||||
expect(s.isExpired, isFalse);
|
||||
});
|
||||
|
||||
test('无过期时间 → false(交由服务端 401 裁决)', () {
|
||||
const s = Session(token: 't');
|
||||
expect(s.isExpired, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user