Files
Kokorone/scripts/smoke/Q.mjs
JefferyandClaude Sonnet 5 5da8b3a63e feat: 完成 Q 群組 — APP(React Native + Expo)
新增 apps/mobile:Expo Router 檔案系統路由(同 Next.js App Router 的
心智模型),底部四分頁(聊天/角色/日常/設定)皆為真實 API 串接(/chat、
/tachie、/characters、/schedule、/health、/push)。聊天分頁完整重用
H/O 群組已驗證過的對話+表情查詢節奏。立繪暫以依情緒 magnitude 調整
透明度的色塊佔位(PixiJS 是瀏覽器 DOM/Canvas API,未移植到 React
Native,需要 react-native-skia 等原生繪圖套件才能重現分層合成,超出
本群組範圍)。

新增 apps/api/src/push/:PushProvider 抽象(第三次套用 F-1/P-4 的
Provider 模式),MockPushProvider 供測試、ExpoPushProvider 是可直接
打真實 HTTP 請求的完整實作(Expo 推播匿名發送不需金鑰,不像 LLM/TTS
需要另外挑供應商待人工確認)。PushToken/PushNotificationLog 記錄裝置
註冊與每次推播嘗試。J 群組 TaskTriggerService 觸發時會嘗試推播給
使用者已註冊裝置,用觸發訊息本身當推播內容。

本環境沒有 Android/iOS 模擬器,改用 `expo start --web` + playwright-core
(同 O 群組手法)驅動無頭瀏覽器驗證四分頁切換、真實 API 串接、聊天
完整往返、推播按鈕在無裝置環境下的優雅降級——不是原生 APP 的完整驗證,
但驗證了畫面邏輯與 API 契約層。過程中發現並修正 CORS 設定只放行網頁版
origin、擋掉 Expo 網頁版請求的問題(永久修正:新增 localhost:8081 白名單,
不影響原生 APP,原生環境沒有 CORS 限制)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:19:20 +08:00

119 lines
5.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const USER_ID = "seed-user-primary";
const CHAR_ID = "smoke-q-char";
const TOKEN = "ExponentPushToken[smoke-q-test-token]";
async function post(path, body) {
const res = await fetch(`http://localhost:${API_PORT}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) {
throw new Error(`POST ${path} 回傳 ${res.status}:${await res.text()}`);
}
return res.json();
}
async function get(path) {
const res = await fetch(`http://localhost:${API_PORT}${path}`);
if (!res.ok) {
throw new Error(`GET ${path} 回傳 ${res.status}`);
}
return res.json();
}
// Q-1/Q-2/Q-3 目前都是行動 APP(React Native + Expo,apps/mobile)消費既有的 API 契約,
// 沒有為 Q 群組新增角色/對話端點——這裡驗證的是 Q 群組唯一新增的後端功能:推播通知(Q-3),
// 以及 APP 賴以運作的既有契約仍然完整(/health、/chat、/characters、/schedule)。
// APP 本身的畫面/導覽/實際串接已在本群組以 `expo start --web` + 無頭瀏覽器手動驗證過
// (見 todo.md 實作記錄),不在這個純後端 smoke test 的範圍內。
export default async function smokeQ() {
await prisma.character.deleteMany({ where: { id: CHAR_ID } });
await prisma.pushToken.deleteMany({ where: { token: TOKEN } });
// Q-1 驗收:APP 能呼叫 api 的 /health 並顯示結果——直接驗證這個契約存在且回應正常。
const health = await get("/health");
if (!health || typeof health.status !== "string") {
throw new Error("/health 應該回傳可顯示的狀態文字");
}
await post("/personality/characters", {
id: CHAR_ID,
source: "ORIGINAL",
buildStatus: "BUILT",
formalName: "測試角色",
basicInfo: "測試角色",
backgroundStory: "測試",
personalityArchetype: "元氣",
likesDislikes: "測試",
goalsObsessions: "測試",
speechStyle: "第一人稱「我」",
initialRelationship: { userId: USER_ID, intimacy: 20, trust: 10 },
});
// Q-2 驗收(後端契約部分):聊天分頁依賴的 /chat 端點能完成一輪對話並回傳更新後的關係/情緒狀態。
const chatResult = await post(`/chat/${CHAR_ID}`, {
userId: USER_ID,
sessionId: "smoke-q-session",
text: "嗨,最近好嗎?",
});
if (!chatResult.text || !chatResult.context) {
throw new Error("聊天分頁需要的一輪對話(含情緒/關係上下文)應該能正常完成");
}
// Q-3 推播通知:註冊裝置 token、送出推播應該留下記錄;J 群組的提醒觸發應該會嘗試推播。
await post("/push/register", { userId: USER_ID, token: TOKEN, platform: "expo" });
const registered = await prisma.pushToken.findUnique({ where: { token: TOKEN } });
if (!registered || registered.userId !== USER_ID) {
throw new Error("裝置推播 token 應該被正確註冊");
}
const sendResult = await post("/push/send-test", { userId: USER_ID, title: "測試標題", body: "測試內容" });
if (sendResult.attempted !== 1 || sendResult.succeeded !== 1) {
throw new Error("已註冊裝置的使用者,推播應該嘗試送出且成功(Mock Provider)");
}
const log = await prisma.pushNotificationLog.findFirst({
where: { userId: USER_ID, title: "測試標題" },
orderBy: { createdAt: "desc" },
});
if (!log || !log.success) {
throw new Error("每次推播嘗試都應該留下記錄");
}
// 重複註冊同一個 token(模擬 APP 重啟)不應該產生重複紀錄。
await post("/push/register", { userId: USER_ID, token: TOKEN, platform: "expo" });
const tokenCount = await prisma.pushToken.count({ where: { token: TOKEN } });
if (tokenCount !== 1) {
throw new Error("重複註冊同一個裝置 token 不應該產生重複紀錄");
}
// J 群組整合:任務觸發時應該嘗試推播給使用者(用實際觸發一個任務來驗證,而不是直接呼叫推播端點)。
const task = await post(`/task/${CHAR_ID}`, {
userId: USER_ID,
type: "ONE_TIME",
content: "測試提醒",
rawText: "提醒我測試",
});
const beforeCount = await prisma.pushNotificationLog.count({ where: { userId: USER_ID } });
await post(`/task/${task.task.id}/fire-now`, {});
const afterCount = await prisma.pushNotificationLog.count({ where: { userId: USER_ID } });
if (afterCount !== beforeCount + 1) {
throw new Error("J 群組建立的提醒觸發時,應該嘗試推播給使用者的已註冊裝置");
}
const taskPushLog = await prisma.pushNotificationLog.findFirst({
where: { userId: USER_ID },
orderBy: { createdAt: "desc" },
});
if (!taskPushLog?.body.includes("測試提醒")) {
throw new Error("任務觸發的推播內容應該包含提醒訊息本身");
}
// 清理本次測試建立的角色與推播資料(cascade 會一併清掉任務、關係、情緒狀態等)。
await prisma.character.deleteMany({ where: { id: CHAR_ID } });
await prisma.pushToken.deleteMany({ where: { token: TOKEN } });
await prisma.pushNotificationLog.deleteMany({ where: { userId: USER_ID, title: { in: ["測試標題", "心音 Kokorone"] } } });
}