import { prisma } from "@kokorone/db"; const API_PORT = process.env.PORT_API ?? "3001"; const USER_ID = "smoke-s-user"; const NOW = "2026-08-19T05:00:00.000Z"; // 週三 13:00 台北,FREE 時段 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}:${await res.text()}`); } return res.json(); } async function createCharacter(id, formalName, archetype, likesDislikes, intimacy = 50) { await post("/personality/characters", { id, source: "ORIGINAL", buildStatus: "BUILT", formalName, basicInfo: `${formalName},測試角色`, backgroundStory: "測試用背景", personalityArchetype: archetype, likesDislikes, goalsObsessions: "測試", speechStyle: "第一人稱「我」", initialRelationship: { userId: USER_ID, intimacy, trust: 50 }, }); } // 讀取一段 SSE 串流直到收滿 expectedTurnCount 個 turn 事件或逾時,回傳收到的 turn 事件陣列(依到達順序)。 async function collectStreamTurns(roomId, afterSeq, expectedTurnCount, timeoutMs = 8000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const collected = []; try { const resp = await fetch(`http://localhost:${API_PORT}/room/${roomId}/stream?afterSeq=${afterSeq}`, { headers: { Accept: "text/event-stream" }, signal: controller.signal, }); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (collected.length < expectedTurnCount) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let frameBreak = buffer.indexOf("\n\n"); while (frameBreak !== -1) { const frame = buffer.slice(0, frameBreak); buffer = buffer.slice(frameBreak + 2); let eventType = "message"; let id = null; const dataLines = []; for (const line of frame.split("\n")) { if (line.startsWith("event:")) eventType = line.slice(6).trim(); else if (line.startsWith("id:")) id = line.slice(3).trim(); else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim()); } if (eventType === "turn" && dataLines.length > 0) { collected.push({ seq: Number(id), data: JSON.parse(dataLines.join("\n")) }); } frameBreak = buffer.indexOf("\n\n"); } } reader.cancel().catch(() => undefined); } finally { clearTimeout(timer); } return collected; } const GENKI_LIKE = "運動、甜食"; const COOL_LIKE = "安靜、看書"; const CHAR_IDS = [ "smoke-s-genki", "smoke-s-cool", "smoke-s-sleeper", "smoke-s-delegate", "smoke-s-genki2", "smoke-s-invite-a", "smoke-s-invite-b", "smoke-s-invite-target", "smoke-s-invite-target2", ]; // T21-P2:P2(大廳呼叫、多方邀請裁決、SSE 續傳、未讀跳轉、SOLO→GROUP 房型切換)端到端驗證。 export default async function smokeS() { await prisma.room.deleteMany({ where: { userId: USER_ID } }); await prisma.callAttemptLog.deleteMany({ where: { userId: USER_ID } }); await prisma.characterRelationship.deleteMany({ where: { characterId: { in: CHAR_IDS } } }); await prisma.scheduleSlot.deleteMany({ where: { characterId: { in: CHAR_IDS } } }); await prisma.character.deleteMany({ where: { id: { in: CHAR_IDS } } }); await prisma.user.upsert({ where: { id: USER_ID }, update: {}, create: { id: USER_ID, name: "S 群組測試使用者" } }); // 1. 答應:大廳喊一位元氣角色,應答應並開 SOLO 房。 await createCharacter("smoke-s-genki", "測試S元氣", "元氣", GENKI_LIKE); const acceptResult = await post("/lobby/say", { userId: USER_ID, text: "測試S元氣,你在嗎?", now: NOW }); if (!acceptResult.accepted.includes("smoke-s-genki") || acceptResult.mode !== "SOLO" || !acceptResult.roomId) { throw new Error("答應情境:應答應並降級開 SOLO 房"); } // 2. 降級為單人房:同時喊一位會答應、一位會婉拒的角色,應只有答應者入房、開 SOLO。 await createCharacter("smoke-s-genki2", "測試S元氣二", "元氣", GENKI_LIKE); await createCharacter("smoke-s-cool", "測試S三無", "三無", COOL_LIKE); const downgradeResult = await post("/lobby/say", { userId: USER_ID, text: "測試S元氣二、測試S三無,你們在嗎?", now: NOW, }); if ( !downgradeResult.accepted.includes("smoke-s-genki2") || !downgradeResult.declined.includes("smoke-s-cool") || downgradeResult.mode !== "SOLO" ) { throw new Error("降級情境:僅一位答應時應降級開 SOLO 房"); } // 3. 全部不答應:只喊會婉拒的角色,不開房,但要有角色台詞(非系統錯誤)。 const declineAllResult = await post("/lobby/say", { userId: USER_ID, text: "測試S三無,你在嗎?", now: NOW }); if (declineAllResult.roomId !== null || !declineAllResult.message || /error|Error|錯誤/.test(declineAllResult.message)) { throw new Error("全部不答應情境:不應開房,且應回傳角色台詞而非系統錯誤"); } // 4. 關係最近者代答:喊睡著的角色,由關係最近且清醒未入房者代答,訊息附上下次可找的時間。 await createCharacter("smoke-s-sleeper", "測試S睡神", "三無", COOL_LIKE); await createCharacter("smoke-s-delegate", "測試S代答", "元氣", GENKI_LIKE); await prisma.scheduleSlot.create({ data: { characterId: "smoke-s-sleeper", dayType: "WEEKDAY", startMinute: 720, endMinute: 900, status: "SLEEP" }, }); await post("/room/relationship/smoke-s-sleeper", { otherCharacterId: "smoke-s-delegate", affinity: 90 }); const delegateResult = await post("/lobby/say", { userId: USER_ID, text: "測試S睡神,你在嗎?", now: NOW }); if (delegateResult.delegatedBy !== "smoke-s-delegate" || !delegateResult.message?.includes("15:00")) { throw new Error("代答情境:應由關係最近且清醒未入房者代答,並附上次可找時間"); } // 5. 多方邀請兩處都收到回覆且只入一間:房內角色各自邀請同一目標,裁決後只入關係較好的那間。 await createCharacter("smoke-s-invite-a", "測試S邀請甲", "元氣", GENKI_LIKE); await createCharacter("smoke-s-invite-b", "測試S邀請乙", "元氣", GENKI_LIKE); await createCharacter("smoke-s-invite-target", "測試S被邀角", "元氣", GENKI_LIKE); const roomA = await post("/room", { userId: USER_ID, characterIds: ["smoke-s-invite-a"], mode: "GROUP" }); const roomB = await post("/room", { userId: USER_ID, characterIds: ["smoke-s-invite-b"], mode: "GROUP" }); await post("/room/relationship/smoke-s-invite-target", { otherCharacterId: "smoke-s-invite-a", affinity: 50 }); await post("/room/relationship/smoke-s-invite-target", { otherCharacterId: "smoke-s-invite-b", affinity: 95 }); await post(`/room/${roomA.id}/invite`, { characterId: "smoke-s-invite-target", invitedByCharacterId: "smoke-s-invite-a", now: NOW }); await post(`/room/${roomB.id}/invite`, { characterId: "smoke-s-invite-target", invitedByCharacterId: "smoke-s-invite-b", now: NOW }); await post(`/room/invitations/smoke-s-invite-target/resolve`, { now: NOW }); const roomAAfterInvite = await get(`/room/${roomA.id}`); const roomBAfterInvite = await get(`/room/${roomB.id}`); if (!roomAAfterInvite.turns.some((t) => t.speakerId === "smoke-s-invite-target")) { throw new Error("多方邀請情境:落選的房間也應收到該角色的回覆(婉拒台詞)"); } if (!roomBAfterInvite.participants.includes("smoke-s-invite-target") || roomAAfterInvite.participants.includes("smoke-s-invite-target")) { throw new Error("多方邀請情境:應只入關係較好的那間,另一間不應在場"); } // 6. 1:1 升級為多人房後規則改變:邀請角色加入 SOLO 房,應轉為 GROUP,且不會因為對方離開而降回 SOLO。 // 用全新的元氣角色當被邀角色(conversationBaseline 高,中性關係下應答應),避免與第 5 項已入房的 // smoke-s-invite-target 衝突(R5:已在其他非大廳房間在場者邀請一律直接婉拒,不會進入待裁決佇列)。 await createCharacter("smoke-s-invite-target2", "測試S被邀角二", "元氣", GENKI_LIKE); const soloForUpgrade = await post("/room/solo", { userId: USER_ID, characterId: "smoke-s-genki" }); await post(`/room/${soloForUpgrade.id}/invite`, { characterId: "smoke-s-invite-target2", invitedByCharacterId: "smoke-s-genki", now: NOW, }); await post(`/room/invitations/smoke-s-invite-target2/resolve`, { now: NOW }); const upgradedRoom = await get(`/room/${soloForUpgrade.id}`); if (upgradedRoom.mode !== "GROUP") { throw new Error("房型切換情境:SOLO 房被邀請角色答應後應轉為 GROUP"); } await prisma.roomMember.updateMany({ where: { roomId: soloForUpgrade.id, characterId: "smoke-s-invite-target2" }, data: { leftAt: new Date(), activeCharacterId: null, leaveReason: "SMOKE_TEST" }, }); const afterLeaveRoom = await prisma.room.findUnique({ where: { id: soloForUpgrade.id } }); if (afterLeaveRoom.mode !== "GROUP") { throw new Error("房型切換情境:對方離開後不應自動降回 SOLO"); } // 7. 未讀跳轉:送兩則訊息後不回報已讀,unreadCount 應等於總則數;回報已讀後歸零。 const unreadRoom = await post("/room/solo", { userId: USER_ID, characterId: "smoke-s-genki2" }); await post(`/room/${unreadRoom.id}/message`, { userId: USER_ID, userLabel: "測試", text: "第一句", now: NOW }); await post(`/room/${unreadRoom.id}/message`, { userId: USER_ID, userLabel: "測試", text: "第二句", now: NOW }); const beforeRead = await get(`/room/${unreadRoom.id}`); if (beforeRead.unread.unreadCount !== beforeRead.turnCount || beforeRead.unread.firstUnreadSeq !== 0) { throw new Error("未讀跳轉情境:未回報已讀前,未讀數應等於總則數、第一則未讀應是第 0 則"); } await post(`/room/${unreadRoom.id}/read`, { userId: USER_ID, lastReadSeq: beforeRead.turnCount - 1 }); const afterRead = await get(`/room/${unreadRoom.id}`); if (afterRead.unread.unreadCount !== 0 || afterRead.unread.firstUnreadSeq !== null) { throw new Error("未讀跳轉情境:回報讀到底後,未讀數應歸零"); } // 8. SSE 續傳不漏訊息:斷線期間(未連線)發生的 turn,重新連線帶 afterSeq 應完整補齊、無重複無遺漏。 const sseRoom = await post("/room/solo", { userId: USER_ID, characterId: "smoke-s-delegate" }); const beforeStream = await get(`/room/${sseRoom.id}`); const lastSeqBefore = beforeStream.turns.length > 0 ? beforeStream.turns[beforeStream.turns.length - 1].seq : -1; await post(`/room/${sseRoom.id}/message`, { userId: USER_ID, userLabel: "測試", text: "斷線期間的話", now: NOW }); const afterMissed = await get(`/room/${sseRoom.id}`); const missedTurns = afterMissed.turns.filter((t) => t.seq > lastSeqBefore); const replayed = await collectStreamTurns(sseRoom.id, lastSeqBefore, missedTurns.length); if (replayed.length !== missedTurns.length) { throw new Error(`SSE 續傳情境:應補齊 ${missedTurns.length} 則錯過的訊息,實際收到 ${replayed.length} 則`); } const replayedSeqs = replayed.map((r) => r.seq).sort((a, b) => a - b); const missedSeqs = missedTurns.map((t) => t.seq).sort((a, b) => a - b); if (JSON.stringify(replayedSeqs) !== JSON.stringify(missedSeqs)) { throw new Error("SSE 續傳情境:補齊的 seq 應與實際錯過的 seq 完全一致,不可重複也不可遺漏"); } // 清理本次測試建立的資料。 await prisma.room.deleteMany({ where: { userId: USER_ID } }); await prisma.callAttemptLog.deleteMany({ where: { userId: USER_ID } }); await prisma.characterRelationship.deleteMany({ where: { characterId: { in: CHAR_IDS } } }); await prisma.scheduleSlot.deleteMany({ where: { characterId: { in: CHAR_IDS } } }); await prisma.character.deleteMany({ where: { id: { in: CHAR_IDS } } }); }