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:
浅念
2026-09-19 00:40:02 +08:00
parent 8bc50e07ef
commit f975321b67
18 changed files with 3430 additions and 0 deletions
@@ -0,0 +1,258 @@
/// 相册域模型往返测试(P3 计划 Task 2)。
///
/// fixture 来源(字段逐一转写):
/// - openapi.yaml components.schemas.AlbumSummary / Album /
/// AlbumTranslation / Category / CategoryTranslation / Member /
/// AdminUserLookupItem / AlbumCreateRequest / AlbumUpdateRequest /
/// CategoryCreateRequest / CategoryUpdateRequest;
/// - services/api/test/albums.e2e-spec.ts `validCreateBody`(zh-CN 完整 +
/// en 仅 title、未填可选字段回读 null)与 201 响应形状断言(status=
/// active、四开关默认值、watermarkVersion=0、categories=[]、
/// publicToken ^[0-9a-z]{20,32}$)。
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/features/albums/models.dart';
// openapi.yaml:AlbumSummary 必返字段全集(+ Album 增量 categories)。
final Map<String, dynamic> albumSummaryFixture = <String, dynamic>{
'id': 'cktragt000001',
'publicToken': 'a1b2c3d4e5f6g7h8i9j0',
'ownerId': 'usr-owner-01',
'status': 'active',
'defaultLocale': 'zh-CN',
'timezone': 'Asia/Shanghai',
'startAt': '2026-10-01T02:00:00.000Z',
'endAt': null,
'uploadEnabled': true,
'publicEnabled': true,
'downloadEnabled': true,
'originalDownloadEnabled': false,
'watermarkVersion': 0,
'translations': [
{
'locale': 'zh-CN',
'title': '2026 公司年会',
'description': '年度盛会',
'venue': '大宴会厅',
'address': '上海市浦东新区世纪大道 100 号',
},
{
'locale': 'en',
'title': 'Annual Party 2026',
'description': null,
'venue': null,
'address': null,
},
],
'createdAt': '2026-09-10T08:00:00.000Z',
'updatedAt': '2026-09-10T08:00:00.000Z',
'statsSyncedAt': null,
};
final Map<String, dynamic> categoryFixture = <String, dynamic>{
'id': 'ckucat0000001',
'albumId': 'cktragt000001',
'sortOrder': 0,
'translations': [
{'locale': 'zh-CN', 'name': '开幕式'},
{'locale': 'en', 'name': 'Opening'},
],
};
void main() {
test('AlbumSummary fromJson/toJson 往返(e2e 201 形状)', () {
final model = AlbumSummary.fromJson(albumSummaryFixture);
expect(model.status, AlbumStatus.active);
expect(model.publicToken, matches(RegExp('^[0-9a-z]{20,32}\$')));
expect(model.translations, hasLength(2));
// e2e:未填可选字段回读 null(契约「存储为 null」)。
expect(model.translations[1].description, isNull);
expect(model.translations[1].venue, isNull);
expect(model.translations[1].address, isNull);
expect(model.toJson(), equals(albumSummaryFixture));
});
test('Album fromJson/toJson 往返(AlbumSummary + categories)', () {
final fixture = {...albumSummaryFixture, 'categories': [categoryFixture]};
final model = Album.fromJson(fixture);
expect(model.categories, hasLength(1));
expect(model.categories.first.id, 'ckucat0000001');
expect(model.categories.first.translations.first.name, '开幕式');
expect(model.toJson(), equals(fixture));
});
test('AlbumSummary 非空 endAt/statsSyncedAt 往返', () {
final fixture = {
...albumSummaryFixture,
'endAt': '2026-10-01T14:00:00.000Z',
'status': 'offline',
'statsSyncedAt': '2026-09-18T01:02:03.500Z',
};
final model = AlbumSummary.fromJson(fixture);
expect(model.endAt, DateTime.utc(2026, 10, 1, 14));
expect(model.status, AlbumStatus.offline);
expect(model.statsSyncedAt!.millisecond, 500);
expect(model.toJson(), equals(fixture));
});
test('Category fromJson/toJson 往返', () {
final model = Category.fromJson(categoryFixture);
expect(model.sortOrder, 0);
expect(model.toJson(), equals(categoryFixture));
});
test('Member fromJson/toJson 往返(members-categories.e2e 形状)', () {
final fixture = <String, dynamic>{
'userId': 'usr-owner-01',
'account': 'album-owner',
'displayName': '张三',
'role': 'owner',
'status': 'active',
};
final model = Member.fromJson(fixture);
expect(model.role, MemberRole.owner);
expect(model.status, MemberStatus.active);
expect(model.toJson(), equals(fixture));
final collaborator = Member.fromJson({...fixture, 'role': 'collaborator'});
expect(collaborator.role, MemberRole.collaborator);
});
test('AdminUserLookupItem fromJson/toJson 往返(脱敏三字段)', () {
final fixture = <String, dynamic>{
'id': 'usr-col-02',
'account': 'collab01',
'displayName': '李四',
};
final model = AdminUserLookupItem.fromJson(fixture);
expect(model.toJson(), equals(fixture));
});
test('AlbumCreateRequest toJson(albums.e2e validCreateBody 同构)', () {
final req = AlbumCreateRequest(
translations: const [
AlbumTranslation(
locale: 'zh-CN',
title: '2026 公司年会',
description: '年度盛会',
venue: '大宴会厅',
address: '上海市浦东新区世纪大道 100 号',
),
AlbumTranslation(locale: 'en', title: 'Annual Party 2026'),
],
defaultLocale: 'zh-CN',
startAt: DateTime.utc(2026, 10, 1, 2),
);
expect(req.toJson(), {
'translations': [
{
'locale': 'zh-CN',
'title': '2026 公司年会',
'description': '年度盛会',
'venue': '大宴会厅',
'address': '上海市浦东新区世纪大道 100 号',
},
{
'locale': 'en',
'title': 'Annual Party 2026',
'description': null,
'venue': null,
'address': null,
},
],
'defaultLocale': 'zh-CN',
// Dart toIso8601String 恒带毫秒——与 e2e 体(.000Z,Date.toISOString
// 毫秒形)逐字节一致。
'startAt': '2026-10-01T02:00:00.000Z',
'endAt': null,
'timezone': 'Asia/Shanghai',
'downloadEnabled': true,
'originalDownloadEnabled': false,
});
});
test('AlbumUpdateRequest toJson 仅序列化提交字段(部分更新语义)', () {
expect(
const AlbumUpdateRequest(uploadEnabled: false).toJson(),
{'uploadEnabled': false},
);
expect(
AlbumUpdateRequest(
startAt: DateTime.utc(2026, 10, 2, 9, 0, 0),
endAt: DateTime.utc(2026, 10, 2, 18),
).toJson(),
{
'startAt': '2026-10-02T09:00:00.000Z',
'endAt': '2026-10-02T18:00:00.000Z',
},
);
// endAt 显式清除:序列化为 "endAt": null(契约:null 清除)。
expect(
const AlbumUpdateRequest(clearEndAt: true).toJson(),
{'endAt': null},
);
// 端提交与显式清除同时设置时,清除语义(null)胜出。
expect(
AlbumUpdateRequest(
endAt: DateTime.utc(2026, 10, 2, 18),
clearEndAt: true,
).toJson(),
{'endAt': null},
);
});
test('CategoryCreateRequest / CategoryUpdateRequest toJson', () {
const translations = [
CategoryTranslation(locale: 'zh-CN', name: '晚宴'),
CategoryTranslation(locale: 'en', name: 'Banquet'),
];
expect(
const CategoryCreateRequest(translations: translations).toJson(),
{
'translations': [
{'locale': 'zh-CN', 'name': '晚宴'},
{'locale': 'en', 'name': 'Banquet'},
],
},
);
expect(
const CategoryCreateRequest(sortOrder: 5, translations: translations)
.toJson()['sortOrder'],
5,
);
expect(
const CategoryUpdateRequest(sortOrder: 2).toJson(),
{'sortOrder': 2},
);
expect(
const CategoryUpdateRequest(translations: translations).toJson(),
{
'translations': [
{'locale': 'zh-CN', 'name': '晚宴'},
{'locale': 'en', 'name': 'Banquet'},
],
},
);
});
test('未知枚举值解析抛 FormatException(fail-fast 防脏数据静默)', () {
expect(
() => AlbumSummary.fromJson({
...albumSummaryFixture,
'status': 'archived',
}),
throwsFormatException,
);
expect(
() => Member.fromJson(const {
'userId': 'u',
'account': 'a',
'displayName': 'd',
'role': 'viewer',
'status': 'active',
}),
throwsFormatException,
);
});
}