Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions server/tests/integration/test_practice_attempts.py
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions server/tests/integration/test_practice_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
46 changes: 46 additions & 0 deletions server/tests/integration/test_tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
36 changes: 36 additions & 0 deletions server/tests/unit/test_storage_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
145 changes: 145 additions & 0 deletions web/e2e/journey.spec.js
Original file line number Diff line number Diff line change
@@ -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");
});
Loading