diff --git a/server/tests/integration/test_practice_attempts.py b/server/tests/integration/test_practice_attempts.py new file mode 100644 index 0000000..5c871cb --- /dev/null +++ b/server/tests/integration/test_practice_attempts.py @@ -0,0 +1,90 @@ +"""独立 Attempt 链路:双读合并、并发 reserve 的 round 唯一性。""" + +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import AsyncMock, patch + +from pymongo import MongoClient + +from tests.conftest import TEST_DB_NAME + +CORRECT_RESULT = {"summary": "ok", "score": 6.0, "standardAnswer": "native", "gaps": [], "progress": None} + + +def test_dual_read_merges_embedded_and_independent_by_round(client, user_id, auth_headers, practice_id): + """迁移过渡期:嵌入 round 1 + 独立 round 2 合并成完整列表,且都带稳定 attemptId。""" + mongo = MongoClient("mongodb://localhost:27017/") + db = mongo[TEST_DB_NAME] + db.practiceSessions.update_one( + {"_id": practice_id}, + {"$push": {"attempts": {"round": 1, "transcript": "embedded take", "score": 5.0}}}, + ) + db.practiceAttempts.insert_one({ + "_id": "pa_independent_round2", + "practiceId": practice_id, + "userId": user_id, + "round": 2, + "transcript": "independent take", + "score": 7.0, + "status": "completed", + }) + mongo.close() + + practice = client.get(f"/api/practice-sessions/{practice_id}", headers=auth_headers).json() + attempts = practice["attempts"] + + assert [a["round"] for a in attempts] == [1, 2] + assert attempts[0]["transcript"] == "embedded take" + assert attempts[0]["attemptId"].startswith("pa_") + assert attempts[1]["attemptId"] == "pa_independent_round2" + + +def test_dual_read_independent_row_wins_over_embedded_same_round(client, user_id, auth_headers, practice_id): + mongo = MongoClient("mongodb://localhost:27017/") + db = mongo[TEST_DB_NAME] + db.practiceSessions.update_one( + {"_id": practice_id}, + {"$push": {"attempts": {"round": 1, "transcript": "stale embedded"}}}, + ) + db.practiceAttempts.insert_one({ + "_id": "pa_winner", + "practiceId": practice_id, + "userId": user_id, + "round": 1, + "transcript": "fresh independent", + "status": "completed", + }) + mongo.close() + + practice = client.get(f"/api/practice-sessions/{practice_id}", headers=auth_headers).json() + + assert len(practice["attempts"]) == 1 + assert practice["attempts"][0]["transcript"] == "fresh independent" + + +def test_concurrent_corrections_get_unique_rounds(client, user_id, auth_headers, practice_id): + """并发完成回调不产生重复 (practiceId, round)。""" + with patch("routes.correct.correct_text", new=AsyncMock(return_value=CORRECT_RESULT)): + with ThreadPoolExecutor(max_workers=3) as pool: + responses = list(pool.map( + lambda i: client.post( + "/api/correct", + json={ + "userId": user_id, + "practiceId": practice_id, + "text": f"concurrent take {i}", + }, + headers=auth_headers, + ), + range(3), + )) + + bodies = [r.json() for r in responses] + assert all(r.status_code == 200 for r in responses) + assert sorted(b["round"] for b in bodies) == [1, 2, 3] + assert len({b["attemptId"] for b in bodies}) == 3 + + mongo = MongoClient("mongodb://localhost:27017/") + docs = list(mongo[TEST_DB_NAME].practiceAttempts.find({"practiceId": practice_id})) + mongo.close() + pairs = [(d["practiceId"], d["round"]) for d in docs] + assert len(pairs) == len(set(pairs)) == 3 diff --git a/server/tests/integration/test_practice_sessions.py b/server/tests/integration/test_practice_sessions.py index 671edf6..c78d6a9 100644 --- a/server/tests/integration/test_practice_sessions.py +++ b/server/tests/integration/test_practice_sessions.py @@ -383,3 +383,42 @@ def test_create_practice_rejects_userid_token_mismatch(client, user_id, auth_hea headers=auth_headers, ) assert resp.status_code == 403 + + +def test_upload_recording_replaces_previous_object( + client, user_id, auth_headers, practice_id, monkeypatch, +): + """同一 attempt 二次上传录音时,旧 OSS 对象要被登记删除。""" + from unittest.mock import AsyncMock, MagicMock + monkeypatch.setattr("routes.practice_sessions.upload_bytes_async", AsyncMock(return_value=None)) + monkeypatch.setattr("routes.practice_sessions.oss_signed_url", MagicMock(return_value="https://signed")) + deleted = [] + + async def _record_delete(key): + deleted.append(key) + + monkeypatch.setattr("routes.practice_sessions.delete_async", _record_delete) + mongo = MongoClient("mongodb://localhost:27017/") + mongo[TEST_DB_NAME].practiceSessions.update_one( + {"_id": practice_id}, {"$push": {"attempts": {"round": 1}}} + ) + mongo.close() + + def upload(tag: bytes) -> dict: + resp = client.post( + f"/api/practice-sessions/{practice_id}/recording", + data={"userId": user_id, "attemptIndex": 0}, + files={"audio": ("r.webm", tag, "audio/webm")}, + headers=auth_headers, + ) + assert resp.status_code == 200 + return resp.json() + + first = upload(b"FIRST_TAKE") + second = upload(b"SECOND_TAKE") + + first_key = first["recording"]["key"] + assert deleted == [first_key] + assert second["recording"]["key"] != first_key + p = client.get(f"/api/practice-sessions/{practice_id}", headers=auth_headers).json() + assert p["attempts"][0]["recording"]["key"] == second["recording"]["key"] diff --git a/server/tests/integration/test_tts.py b/server/tests/integration/test_tts.py index c72bbfe..4986551 100644 --- a/server/tests/integration/test_tts.py +++ b/server/tests/integration/test_tts.py @@ -83,3 +83,49 @@ def test_tts_rejects_missing_token(client): resp = client.post("/api/tts", json={"text": "Hi"}) assert resp.status_code == 401 fake.assert_not_called() + + +def test_tts_unknown_practice_404(client, auth_headers): + resp = client.post( + "/api/tts", + json={"text": "Hi", "practiceId": "ps_does_not_exist"}, + headers=auth_headers, + ) + assert resp.status_code == 404 + + +def test_tts_without_any_attempt_409(client, auth_headers, practice_id): + resp = client.post( + "/api/tts", + json={"text": "Hi", "practiceId": practice_id}, + headers=auth_headers, + ) + assert resp.status_code == 409 + + +def test_tts_archives_into_legacy_embedded_attempt(client, auth_headers, practice_id): + """独立 Attempt 不存在时,speechAssets 回退写嵌入数组。""" + mongo = MongoClient("mongodb://localhost:27017/") + mongo[TEST_DB_NAME].practiceSessions.update_one( + {"_id": practice_id}, {"$push": {"attempts": {"round": 1}}} + ) + mongo.close() + fake = AsyncMock(return_value="https://oss.example/speech.wav?sig=x") + with patch("routes.tts.speak_url", new=fake): + resp = client.post( + "/api/tts", + json={ + "text": "Could you remake my latte?", + "practiceId": practice_id, + "purpose": "standard-answer", + }, + headers=auth_headers, + ) + assert resp.status_code == 200 + mongo = MongoClient("mongodb://localhost:27017/") + practice = mongo[TEST_DB_NAME].practiceSessions.find_one({"_id": practice_id}) + mongo.close() + assets = practice["attempts"][0]["speechAssets"] + assert len(assets) == 1 + assert assets[0]["purpose"] == "standard-answer" + assert "/attempts/pa_" in assets[0]["key"] diff --git a/server/tests/unit/test_storage_paths.py b/server/tests/unit/test_storage_paths.py index 1f6dc36..b979d84 100644 --- a/server/tests/unit/test_storage_paths.py +++ b/server/tests/unit/test_storage_paths.py @@ -63,3 +63,39 @@ def test_attempt_path_requires_stable_attempt_id(): "rec_1", "webm", ) + + +@pytest.mark.parametrize("created_at", [ + "2026-08-13 17:24:36.826000", + "2026-08-13T17:24:36+00:00", + "20260813172436", +]) +def test_month_accepts_string_created_at(created_at): + key = recording_original_key( + PracticeAssetContext("u_1", created_at, "ps_1", "pa_1"), "rec_1", "webm", + ) + + assert "/202608/" in key + + +@pytest.mark.parametrize("created_at", ["", "2026", "abcdefgh", None]) +def test_month_rejects_values_without_year_month(created_at): + with pytest.raises(ValueError, match="year and month"): + recording_original_key( + PracticeAssetContext("u_1", created_at, "ps_1", "pa_1"), "rec_1", "webm", + ) + + +def test_speech_path_rejects_unknown_purpose(): + with pytest.raises(ValueError, match="purpose"): + speech_key(CONTEXT, "karaoke", "tts_a", "wav") + + +def test_avatar_key_rejects_unknown_variant(): + with pytest.raises(ValueError, match="variant"): + avatar_key("u_1", "av_1", "medium") + + +def test_feedback_image_rejects_unknown_extension(): + with pytest.raises(ValueError, match="extension"): + feedback_image_key("u_1", CREATED_AT, "fb_1", "fi_1", "exe") diff --git a/web/e2e/journey.spec.js b/web/e2e/journey.spec.js new file mode 100644 index 0000000..36484af --- /dev/null +++ b/web/e2e/journey.spec.js @@ -0,0 +1,145 @@ +import { expect, test } from "@playwright/test"; + +// 无后端链路冒烟:mock /api/**,覆盖登录→派题、结果页 ?attempt= 还原与切换、 +// 历史→详情、分享页精确轮次。多引擎/多视口下跑,防页面级回归。 +const USER = { + userId: "u_journey", + phone: "13800000000", + nickname: "journey", + sourceType: "ai_test", + token: "tok_journey", +}; + +const SCENARIO_NEXT = { + scenarioId: "sc_journey", + kind: "task", + title: "咖啡店重做饮品", + where: "咖啡店 · 西雅图", + story: "店员把你的热拿铁做成了冰拿铁。", + mission: "礼貌说明问题,请店员重做。", + points: ["说明饮品做错了"], + imageUrl: "", + videoUrl: "", + isCustom: false, + preferenceMatch: "fallback", + targetWords: [], +}; + +const SESSION = { + _id: "sess_journey", + userId: USER.userId, + scenarioId: "sc_journey", + title: "咖啡店重做饮品", + topic: "咖啡店 · 西雅图", + mode: "scenario", + scenario: { + title: "拿错了饮品", + where: "咖啡店 · 西雅图", + story: "店员把你的热拿铁做成了冰拿铁。", + mission: "礼貌说明问题,请店员重做。", + points: ["说明饮品做错了"], + }, + attempts: [ + { + attemptId: "pa_j1", round: 1, transcript: "first round", + summary: "ROUND_ONE_SUMMARY", score: 5.0, gaps: [], standardAnswer: "SA_ONE", standardAnswerNotes: [], + }, + { + attemptId: "pa_j2", round: 2, transcript: "second round", + summary: "ROUND_TWO_SUMMARY", score: 7.0, gaps: [], standardAnswer: "SA_TWO", standardAnswerNotes: [], + }, + ], +}; + +async function setup(page, { loggedIn = true } = {}) { + await page.route("https://fonts.googleapis.com/**", (route) => route.fulfill({ + body: "", + contentType: "text/css", + })); + await page.route("https://fonts.gstatic.com/**", (route) => route.abort()); + if (loggedIn) { + await page.addInitScript((user) => { + localStorage.setItem("english-speak-user", JSON.stringify(user)); + localStorage.setItem("speakup_lang", "en"); + localStorage.setItem( + `speakup-practice-preferences:${user.userId}`, + JSON.stringify({ level: "daily", purpose: "travel" }), + ); + }, USER); + } + await page.route("**/api/**", (route) => { + const { pathname } = new URL(route.request().url()); + const method = route.request().method(); + if (method === "POST" && pathname === "/api/auth/login") { + return route.fulfill({ json: USER }); + } + if (pathname === "/api/scenarios/next") { + return route.fulfill({ json: SCENARIO_NEXT }); + } + if (pathname === "/api/practice-sessions" && method === "GET") { + return route.fulfill({ json: [SESSION] }); + } + if (pathname === "/api/practice-sessions" && method === "POST") { + return route.fulfill({ json: SESSION }); + } + if (pathname === "/api/practice-sessions/sess_journey") { + return route.fulfill({ json: SESSION }); + } + if (pathname === "/api/share/tok_journey") { + return route.fulfill({ json: { ...SESSION, ownerNickname: USER.nickname, shared: true } }); + } + if (pathname === "/api/feedbacks") { + return route.fulfill({ json: [] }); + } + return route.fulfill({ json: {} }); + }); +} + +test("登录成功后选题开始并渲染场景", async ({ page }) => { + await setup(page, { loggedIn: false }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await page.locator("input[type='tel']").fill("13800001234"); + await page.locator("button").click(); + + await expect(page).toHaveURL(/\/practice$/); + await page.getByRole("button", { name: "Start practicing" }).click(); + + await expect(page).toHaveURL(/\/practice\/sess_journey/); + await expect(page.getByText("店员把你的热拿铁做成了冰拿铁。").first()).toBeVisible(); +}); + +test("结果页 attempt 参数精确还原对应轮次", async ({ page }) => { + await setup(page); + await page.goto("/practice/sess_journey?attempt=pa_j2", { waitUntil: "domcontentloaded" }); + + await expect(page.getByText("ROUND_TWO_SUMMARY")).toBeVisible(); + await expect(page.getByText("Attempt #2")).toBeVisible(); + + await page.goto("/practice/sess_journey?attempt=pa_j1", { waitUntil: "domcontentloaded" }); + await expect(page.getByText("ROUND_ONE_SUMMARY")).toBeVisible(); + await expect(page.getByText("Attempt #1")).toBeVisible(); +}); + +test("历史列表进入会话详情", async ({ page }) => { + await setup(page); + await page.goto("/history", { waitUntil: "domcontentloaded" }); + + await expect(page.getByText("咖啡店重做饮品").first()).toBeVisible(); + await page.locator(".history-headline").first().click(); + + await expect(page).toHaveURL(/\/history\/sess_journey/); + await expect(page.locator(".attempt-tab")).toHaveCount(2); + await expect(page.getByText("ROUND_TWO_SUMMARY")).toBeVisible(); + + await page.locator(".attempt-tab").first().click(); + await expect(page.getByText("ROUND_ONE_SUMMARY")).toBeVisible(); +}); + +test("分享页按 attempt 参数打开被分享轮次", async ({ page }) => { + await setup(page, { loggedIn: false }); + await page.goto("/s/tok_journey?attempt=pa_j1", { waitUntil: "domcontentloaded" }); + + await expect(page.getByText("ROUND_ONE_SUMMARY")).toBeVisible(); + await expect(page.locator(".attempt-tab.active")).toHaveText("Attempt 1"); +});