test: add wechat miniprogram auth smoke

This commit is contained in:
2026-05-12 18:57:27 +08:00
parent aec9142481
commit 26139f80d3
6 changed files with 139 additions and 14 deletions

View File

@@ -4,7 +4,7 @@
## 文档列表 ## 文档列表
- [WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md](./WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md):记录微信小程序 `web-view` 壳的最小接入范围、需要填写的 H5 业务域名、微信后台配置和后续原生化边界。 - [WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md](./WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md):记录微信小程序 `web-view` 壳的最小接入范围、需要填写的 H5 业务域名、微信后台配置`npm run check:wechat-miniprogram-auth` 可重复登录链路 smoke 和后续原生化边界。
- [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。 - [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。
- [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。 - [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。
- [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。 - [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。

View File

@@ -143,6 +143,20 @@ https://你的H5业务域名/#auth_provider=wechat&auth_token=<系统JWT>&auth_b
## 7. 验收口径 ## 7. 验收口径
可重复自动化 smoke
```bash
npm run check:wechat-miniprogram-auth
```
该命令固定覆盖三段链路:
1. 静态确认 `miniprogram/pages/web-view/index.js` 会请求 `/api/auth/wechat/miniprogram-login`,携带 `mini_program / wechat_mini_program` 客户端来源头,并把 `auth_provider/auth_token/auth_binding_status` 拼入 H5 hash。
2. 运行 `api-server` 定向测试 `wechat_miniprogram_login_returns_system_token_and_marks_session_source`,断言小程序登录返回 `token/bindingStatus/user`、写入 refresh cookie并且 `/api/auth/sessions` 能看到 `clientType=mini_program``clientRuntime=wechat_mini_program``miniProgramAppId`
3. 运行前端 `authService` 定向测试,断言 `consumeAuthCallbackResult()` 会消费 `#auth_provider=wechat&auth_token=...&auth_binding_status=...`、保存 access token并清理地址栏 hash。
手工联调仍按以下口径确认真实微信与域名配置:
1. 微信开发者工具打开项目根目录后,识别 `miniprogram/` 为小程序源码目录。 1. 微信开发者工具打开项目根目录后,识别 `miniprogram/` 为小程序源码目录。
2. 未填写 `WEB_VIEW_ENTRY_URL``API_BASE_URL` 时,页面显示配置提示,不出现空白页。 2. 未填写 `WEB_VIEW_ENTRY_URL``API_BASE_URL` 时,页面显示配置提示,不出现空白页。
3. 填写已配置业务域名后,小程序先请求 `/api/auth/wechat/miniprogram-login` 3. 填写已配置业务域名后,小程序先请求 `/api/auth/wechat/miniprogram-login`

View File

@@ -25,6 +25,7 @@
"assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs", "assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs",
"check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs", "check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs",
"check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs", "check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs",
"check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs",
"check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs", "check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs",
"lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
"lint:guardrails": "npm run lint:eslint", "lint:guardrails": "npm run lint:eslint",

View File

@@ -0,0 +1,114 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = process.cwd();
const failures = [];
const smokeSteps = [
{
label: '小程序壳请求与 hash 回跳静态检查',
run: checkMiniProgramShell,
},
{
label: 'api-server 小程序登录与会话来源测试',
run: () =>
runCommand('cargo', [
'test',
'-p',
'api-server',
'wechat_miniprogram_login_returns_system_token_and_marks_session_source',
'--manifest-path',
'server-rs/Cargo.toml',
'--',
'--nocapture',
]),
},
{
label: 'H5 auth hash 消费测试',
run: () =>
runCommand(process.execPath, [
fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)),
'run',
'src/services/authService.test.ts',
'-t',
'consumes auth callback hash and persists the returned access token',
]),
},
];
for (const step of smokeSteps) {
console.log(`[wechat-miniprogram-auth-smoke] ${step.label}`);
step.run();
}
if (failures.length > 0) {
console.error('\n[wechat-miniprogram-auth-smoke] 未通过:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('\n[wechat-miniprogram-auth-smoke] 通过');
function checkMiniProgramShell() {
const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js');
const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts');
ensureNeedles(shellPath, [
'/api/auth/wechat/miniprogram-login',
"'x-client-type': MINI_PROGRAM_CLIENT_TYPE",
"'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME",
'auth_provider',
'auth_token',
'auth_binding_status',
'bindingStatus',
]);
// 中文注释:这里锁定 H5 消费回跳 hash 的真实测试输入,避免只检查实现文本。
ensureNeedles(authServiceTestPath, [
'#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone',
'consumeAuthCallbackResult()',
"bindingStatus: 'pending_bind_phone'",
"expect(getStoredAccessToken()).toBe('jwt-callback-token')",
]);
}
function ensureNeedles(relativeOrFullPath, needles) {
if (!existsSync(relativeOrFullPath)) {
failures.push(`缺少文件:${relativeOrFullPath}`);
return;
}
const content = readFileSync(relativeOrFullPath, 'utf8');
for (const needle of needles) {
if (!content.includes(needle)) {
failures.push(`${relativeOrFullPath} 缺少内容:${needle}`);
}
}
}
function runCommand(command, args) {
const result = spawnSync(command, args, {
cwd: repoRoot,
env: process.env,
shell: false,
stdio: 'inherit',
});
if (result.error) {
failures.push(`${command} 启动失败:${result.error.message}`);
return;
}
if (result.signal) {
failures.push(`${command} 被信号终止:${result.signal}`);
return;
}
if ((result.status ?? 0) !== 0) {
failures.push(`${command} ${args.join(' ')} 退出码 ${result.status}`);
}
}

View File

@@ -3372,13 +3372,6 @@ fn match3d_bad_request(
) )
} }
fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d",
"message": message.into(),
}))
}
fn map_match3d_client_error(error: SpacetimeClientError) -> AppError { fn map_match3d_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error { let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,

View File

@@ -57,8 +57,8 @@ use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord,
PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
@@ -2061,7 +2061,9 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft
level_name: level.level_name, level_name: level.level_name,
picture_description: level.picture_description, picture_description: level.picture_description,
picture_reference: level.picture_reference, picture_reference: level.picture_reference,
background_music: level.background_music.map(map_puzzle_audio_asset_record_response), background_music: level
.background_music
.map(map_puzzle_audio_asset_record_response),
candidates: level candidates: level
.candidates .candidates
.into_iter() .into_iter()
@@ -2667,7 +2669,9 @@ fn parse_puzzle_level_records_from_module_json(
level_name: level.level_name, level_name: level.level_name,
picture_description: level.picture_description, picture_description: level.picture_description,
picture_reference: level.picture_reference, picture_reference: level.picture_reference,
background_music: level.background_music.map(map_puzzle_audio_asset_domain_record), background_music: level
.background_music
.map(map_puzzle_audio_asset_domain_record),
candidates: level candidates: level
.candidates .candidates
.into_iter() .into_iter()
@@ -4608,8 +4612,7 @@ mod tests {
let levels_json = serialize_puzzle_levels_response(&request_context, &[level]) let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
.expect("levels should serialize"); .expect("levels should serialize");
let payload: Value = let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
serde_json::from_str(&levels_json).expect("levels json should parse");
assert_eq!( assert_eq!(
payload[0]["background_music"]["audio_src"], payload[0]["background_music"]["audio_src"],
Value::String("/generated-puzzle-assets/audio.mp3".to_string()) Value::String("/generated-puzzle-assets/audio.mp3".to_string())