vignette/apps/web/e2e/voice.spec.ts
Yun Chan 778e8526d4 세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
2026-07-02 02:50:36 +09:00

151 lines
5.6 KiB
TypeScript

import { expect, test, type Page, type TestInfo } from "@playwright/test";
import { completeOnboarding, fetchAvailablePersona } from "./support";
interface WsResult {
code: number;
messages: string[];
}
async function signInLearner(page: Page, testInfo: TestInfo, label: string) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: `voice.${label}.${testInfo.project.name}.${testInfo.workerIndex}@hs.ac.kr`,
role: "learner",
display_name: `Voice ${label}`,
},
});
expect(res.ok(), await res.text()).toBeTruthy();
await completeOnboarding(page, {
legal_name: `Voice ${label}`,
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-4444-4444",
contact_address: "경기도 오산시 한신대학교",
nickname: `Voice ${label}`,
self_introduction: "음성 WebSocket 경계 검증용 E2E 사용자입니다.",
});
}
async function openVoiceSocket(page: Page, path: string): Promise<WsResult> {
return page.evaluate(
({ wsPath }) =>
new Promise<WsResult>((resolve) => {
const url = new URL(wsPath, window.location.href);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(url.href);
const messages: string[] = [];
const timeout = window.setTimeout(() => {
ws.close();
resolve({ code: -1, messages });
}, 5000);
ws.onmessage = (event) => {
messages.push(String(event.data));
};
ws.onclose = (event) => {
window.clearTimeout(timeout);
resolve({ code: event.code, messages });
};
ws.onerror = () => {
messages.push(JSON.stringify({ type: "error", detail: "browser websocket error" }));
};
}),
{ wsPath: path },
);
}
function parsedMessages(result: WsResult) {
return result.messages.map((message) => JSON.parse(message) as { type: string; detail?: string });
}
test.describe("voice websocket auth boundary", () => {
test("advertises only voice presets accepted by user preferences", async ({ page }, testInfo) => {
await signInLearner(page, testInfo, "presets");
const presetsResponse = await page.request.get("/api/users/me/voice-presets");
expect(presetsResponse.ok(), await presetsResponse.text()).toBeTruthy();
const presets = (await presetsResponse.json()) as { id: string; voice_id: string }[];
const ids = presets.map((preset) => preset.id);
expect(ids).toEqual(["soft-young-fem", "calm-adult-male", "warm-adult-fem", "neutral"]);
expect(ids).not.toContain("calm-adult-fem");
expect(ids).not.toContain("steady-adult-male");
for (const id of ids) {
const saveResponse = await page.request.patch("/api/users/me/preferences", {
data: { voice_preset_id: id },
});
expect(saveResponse.ok(), await saveResponse.text()).toBeTruthy();
expect(await saveResponse.json()).toMatchObject({ voice_preset_id: id });
}
const unsupported = await page.request.patch("/api/users/me/preferences", {
data: { voice_preset_id: "calm-adult-fem" },
});
expect(unsupported.status(), await unsupported.text()).toBe(422);
});
test("rejects unauthenticated websocket clients before degraded voice handling", async ({ page }) => {
await page.goto("/login");
const result = await openVoiceSocket(page, "/api/voice/ws?persona_code=UNAUTHENTICATED");
const messages = parsedMessages(result);
expect(result.code).toBe(1008);
expect(messages).toContainEqual({ type: "error", detail: "not authenticated" });
expect(messages.some((message) => message.type === "degraded")).toBeFalsy();
});
test("rejects binding another learner's session id", async ({ page }, testInfo) => {
await signInLearner(page, testInfo, "owner");
const persona = await fetchAvailablePersona(page);
const start = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
expect(start.ok(), await start.text()).toBeTruthy();
const { session_id } = (await start.json()) as { session_id: string };
await signInLearner(page, testInfo, "other");
await page.goto("/learn");
const result = await openVoiceSocket(
page,
`/api/voice/ws?session_id=${encodeURIComponent(session_id)}`,
);
const messages = parsedMessages(result);
expect(result.code).toBe(1008);
expect(messages).toContainEqual({
type: "error",
detail: "session does not belong to user",
});
expect(messages.some((message) => message.type === "degraded")).toBeFalsy();
});
test("rejects an existing voice session after consent withdrawal @single-run", async ({
page,
}, testInfo) => {
await signInLearner(page, testInfo, "withdrawn");
const persona = await fetchAvailablePersona(page);
const start = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
expect(start.ok(), await start.text()).toBeTruthy();
const { session_id } = (await start.json()) as { session_id: string };
const withdrawn = await page.request.delete("/api/auth/consent");
expect(withdrawn.ok(), await withdrawn.text()).toBeTruthy();
await page.goto("/login");
const result = await openVoiceSocket(
page,
`/api/voice/ws?session_id=${encodeURIComponent(session_id)}`,
);
const messages = parsedMessages(result);
expect(result.code).toBe(1008);
expect(messages).toContainEqual({ type: "error", detail: "consent_required" });
expect(messages.some((message) => message.type === "degraded")).toBeFalsy();
});
});