From 604c3fca6616b13a5ab83ec2f372476b75c740e5 Mon Sep 17 00:00:00 2001 From: bob798 Date: Fri, 22 May 2026 00:00:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(test):=20usePractice=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=20/bbc-eaw=20=E2=86=92=20/articles/episodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/composables/__tests__/usePractice.spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/composables/__tests__/usePractice.spec.js b/frontend/src/composables/__tests__/usePractice.spec.js index bbad920..b27c20a 100644 --- a/frontend/src/composables/__tests__/usePractice.spec.js +++ b/frontend/src/composables/__tests__/usePractice.spec.js @@ -159,7 +159,7 @@ describe('usePractice · 发音练习 API', () => { const out = await getEawEpisode('episode with spaces') expect(authFetchMock).toHaveBeenCalledWith( - '/bbc-eaw/episodes/episode%20with%20spaces', + '/articles/episodes/episode%20with%20spaces', ) expect(out).toEqual(payload) }) @@ -195,12 +195,12 @@ describe('usePractice · 发音练习 API', () => { await expect(getSource(42)).rejects.toThrow('加载失败') }) - it('listEawEpisodes: GET /bbc-eaw/episodes?limit=100 默认 + 返回 JSON / throw on !ok', async () => { + it('listEawEpisodes: GET /articles/episodes?limit=100 默认 + 返回 JSON / throw on !ok', async () => { const payload = { episodes: [{ slug: 'ep1' }, { slug: 'ep2' }] } authFetchMock.mockResolvedValueOnce({ ok: true, json: async () => payload }) const { listEawEpisodes } = usePractice() const out = await listEawEpisodes() - expect(authFetchMock).toHaveBeenCalledWith('/bbc-eaw/episodes?limit=100') + expect(authFetchMock).toHaveBeenCalledWith('/articles/episodes?limit=100') expect(out).toEqual(payload) authFetchMock.mockResolvedValueOnce({ ok: false }) From 9457cbe5a783a2de45a5aa7b1c2903f101d1cf16 Mon Sep 17 00:00:00 2001 From: bob798 Date: Fri, 22 May 2026 00:01:29 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E6=96=B0?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=20+=20?= =?UTF-8?q?=E5=86=92=E7=83=9F=E6=B5=8B=E8=AF=95=20+=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增测试文件: - tests/test_articles_episodes_integration.py — /articles/episodes/* 路由集成测试 (10) - tests/test_library_smoke.py — Library 冒烟 + 边界测试 (17) - frontend/.../tokenize-edge.spec.js — tokenize 分词边界用例 (10) - frontend/.../WordCard.spec.js — WordCard 组件测试 (6) 覆盖: - #42: series 字段在 API 响应中的验证、episodes 过滤、start/rate/due 端点 - #46: stream explain 回填、refetch、空输入边界、超长文本截断 - #43: emoji/CJK/标点/换行分词边界、WordCard 渲染/交互/事件 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../translate/__tests__/WordCard.spec.js | 72 ++++ .../translate/__tests__/tokenize-edge.spec.js | 93 +++++ tests/test_articles_episodes_integration.py | 166 +++++++++ tests/test_library_smoke.py | 341 ++++++++++++++++++ 4 files changed, 672 insertions(+) create mode 100644 frontend/src/components/translate/__tests__/WordCard.spec.js create mode 100644 frontend/src/components/translate/__tests__/tokenize-edge.spec.js create mode 100644 tests/test_articles_episodes_integration.py create mode 100644 tests/test_library_smoke.py diff --git a/frontend/src/components/translate/__tests__/WordCard.spec.js b/frontend/src/components/translate/__tests__/WordCard.spec.js new file mode 100644 index 0000000..af09c31 --- /dev/null +++ b/frontend/src/components/translate/__tests__/WordCard.spec.js @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import WordCard from '../WordCard.vue' + +describe('WordCard', () => { + it('test_renders_word_and_phonetic', () => { + const wrapper = mount(WordCard, { + props: { word: 'hello', phonetic: 'həˈloʊ' }, + }) + expect(wrapper.find('.card-word').text()).toBe('hello') + expect(wrapper.find('.card-ipa').text()).toBe('həˈloʊ') + }) + + it('test_renders_loading_state', () => { + const wrapper = mount(WordCard, { + props: { word: 'hello', loading: true }, + }) + expect(wrapper.find('.card-loading').text()).toBe('加载中...') + }) + + it('test_renders_explanation', () => { + const wrapper = mount(WordCard, { + props: { + word: 'hello', + explanation: { + meaning: '你好', + meanings: ['打招呼'], + example: 'Hello world', + }, + }, + }) + expect(wrapper.find('.card-meaning').text()).toBe('你好') + expect(wrapper.find('.card-meanings').text()).toContain('打招呼') + expect(wrapper.find('.card-example').text()).toContain('Hello world') + }) + + it('test_close_button_emits_close', async () => { + const wrapper = mount(WordCard, { + props: { word: 'hello' }, + }) + await wrapper.find('.card-close').trigger('click') + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('test_renders_without_explanation', () => { + const wrapper = mount(WordCard, { + props: { word: 'hello', explanation: null, loading: false }, + }) + expect(wrapper.find('.card-body').exists()).toBe(false) + }) + + it('test_renders_multiple_meanings', () => { + const wrapper = mount(WordCard, { + props: { + word: 'run', + explanation: { + meaning: '跑', + meanings: ['奔跑', '运行', '经营'], + example: null, + }, + }, + }) + const items = wrapper.findAll('.meaning-item') + expect(items).toHaveLength(3) + expect(items[0].text()).toContain('1.') + expect(items[0].text()).toContain('奔跑') + expect(items[1].text()).toContain('2.') + expect(items[1].text()).toContain('运行') + expect(items[2].text()).toContain('3.') + expect(items[2].text()).toContain('经营') + }) +}) diff --git a/frontend/src/components/translate/__tests__/tokenize-edge.spec.js b/frontend/src/components/translate/__tests__/tokenize-edge.spec.js new file mode 100644 index 0000000..b0338c1 --- /dev/null +++ b/frontend/src/components/translate/__tests__/tokenize-edge.spec.js @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { tokenize } from '../tokenize.js' + +describe('tokenize — edge cases', () => { + it('emoji — "Hello 👋 world" → emoji surrogate halves are non-interactive punct tokens', () => { + // The tokenizer iterates by JS code unit (text[i]), so a 2-code-unit emoji + // like 👋 (U+1F44B) is split into two surrogate halves, each becoming a + // separate non-interactive punct token. + const tokens = tokenize('Hello 👋 world') + // Positions: Hello(word) ' '(space) \uD83D(punct) \uDC4B(punct) ' '(space) world(word) + expect(tokens).toHaveLength(6) + const surrogates = tokens.filter((t) => t.kind === 'punct') + expect(surrogates).toHaveLength(2) + surrogates.forEach((t) => { + expect(t.interactive).toBe(false) + }) + }) + + it('consecutive punctuation — "Hello!!!" → 3 separate punct tokens', () => { + const tokens = tokenize('Hello!!!') + const excls = tokens.filter((t) => t.text === '!') + expect(excls).toHaveLength(3) + excls.forEach((t) => { + expect(t.kind).toBe('punct') + expect(t.interactive).toBe(false) + }) + }) + + it('number-only — "123" → 3 non-interactive punct tokens (digits)', () => { + const tokens = tokenize('123') + expect(tokens).toHaveLength(3) + tokens.forEach((t) => { + expect(t.kind).toBe('punct') + expect(t.interactive).toBe(false) + }) + }) + + it('mixed numbers and words — "abc123def" → "abc" word + "1","2","3" puncts + "def" word', () => { + const tokens = tokenize('abc123def') + expect(tokens).toHaveLength(5) + expect(tokens[0]).toMatchObject({ kind: 'word', text: 'abc', interactive: true }) + expect(tokens[1]).toMatchObject({ kind: 'punct', text: '1', interactive: false }) + expect(tokens[2]).toMatchObject({ kind: 'punct', text: '2', interactive: false }) + expect(tokens[3]).toMatchObject({ kind: 'punct', text: '3', interactive: false }) + expect(tokens[4]).toMatchObject({ kind: 'word', text: 'def', interactive: true }) + }) + + it("trailing apostrophe — \"don't'\" → single word token \"don't'\" (greedy match)", () => { + const tokens = tokenize("don't'") + expect(tokens).toHaveLength(1) + expect(tokens[0]).toMatchObject({ kind: 'word', text: "don't'", interactive: true }) + }) + + it('all CJK — "你好世界" → 4 interactive tokens, each single char', () => { + const tokens = tokenize('你好世界') + expect(tokens).toHaveLength(4) + ;['你', '好', '世', '界'].forEach((ch, i) => { + expect(tokens[i]).toMatchObject({ kind: 'cjk', text: ch, interactive: true }) + }) + }) + + it('CJK with punctuation — "你好!世界。" → 6 tokens (你 好 ! 世 界 。)', () => { + const tokens = tokenize('你好!世界。') + expect(tokens).toHaveLength(6) + expect(tokens[0]).toMatchObject({ kind: 'cjk', text: '你', interactive: true }) + expect(tokens[1]).toMatchObject({ kind: 'cjk', text: '好', interactive: true }) + expect(tokens[2]).toMatchObject({ kind: 'punct', text: '!', interactive: false }) + expect(tokens[3]).toMatchObject({ kind: 'cjk', text: '世', interactive: true }) + expect(tokens[4]).toMatchObject({ kind: 'cjk', text: '界', interactive: true }) + expect(tokens[5]).toMatchObject({ kind: 'punct', text: '。', interactive: false }) + }) + + it('newlines — "hello\\nworld" → "hello" word + "\\n" space + "world" word', () => { + const tokens = tokenize('hello\nworld') + expect(tokens).toHaveLength(3) + expect(tokens[0]).toMatchObject({ kind: 'word', text: 'hello', interactive: true }) + expect(tokens[1]).toMatchObject({ kind: 'space', text: '\n', interactive: false }) + expect(tokens[2]).toMatchObject({ kind: 'word', text: 'world', interactive: true }) + }) + + it('tab character — "hello\\tworld" → tab is space token', () => { + const tokens = tokenize('hello\tworld') + expect(tokens).toHaveLength(3) + expect(tokens[1]).toMatchObject({ kind: 'space', text: '\t', interactive: false }) + }) + + it('very long word — "supercalifragilisticexpialidocious" → single word token', () => { + const word = 'supercalifragilisticexpialidocious' + const tokens = tokenize(word) + expect(tokens).toHaveLength(1) + expect(tokens[0]).toMatchObject({ kind: 'word', text: word, interactive: true }) + }) +}) diff --git a/tests/test_articles_episodes_integration.py b/tests/test_articles_episodes_integration.py new file mode 100644 index 0000000..51ae315 --- /dev/null +++ b/tests/test_articles_episodes_integration.py @@ -0,0 +1,166 @@ +"""Integration tests for /articles/episodes/* routes. + +Covers: + - GET /articles/episodes (list, series filter) + - GET /articles/episodes/{slug} (detail, 404) + - POST /articles/episodes/{slug}/start (create card, 404) + - POST /articles/episodes/{slug}/rate (success, bad rating) + - GET /articles/due +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session as OrmSession + +from main import app +from app.models.db import engine, ArticleEpisode, BbcArticleCard, BbcArticleQuestion +from app.routers.auth import get_current_user_id + +USER = "test_user_episodes_integ" +SLUG = "test-ep-integ" + + +# ── Auth override ────────────────────────────────────────────────────────────── + +app.dependency_overrides[get_current_user_id] = lambda: USER + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +# ── Cleanup fixture ──────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def _reset(): + """Wipe test rows before and after each test.""" + def _clean(): + with OrmSession(engine) as s: + s.query(BbcArticleCard).filter_by(user_id=USER).delete() + s.query(BbcArticleQuestion).filter_by(slug=SLUG).delete() + s.query(ArticleEpisode).filter_by(slug=SLUG).delete() + s.commit() + + _clean() + with OrmSession(engine) as s: + s.add(ArticleEpisode( + series="bbc_eaw", + slug=SLUG, + url="https://example.com/test", + title="Test Episode", + description="Test", + transcript_json='[{"speaker":"A","text":"Hello world."}]', + phrases_json='["hello"]', + transcript_turns=1, + )) + s.commit() + + yield + + _clean() + + +# ── Tests ────────────────────────────────────────────────────────────────────── + +def test_list_episodes_returns_series_field(client): + resp = client.get("/articles/episodes") + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + matching = [item for item in data["items"] if item["slug"] == SLUG] + assert len(matching) == 1 + assert matching[0]["series"] == "bbc_eaw" + + +def test_list_episodes_filter_by_series(client): + resp = client.get("/articles/episodes?series=bbc_eaw") + assert resp.status_code == 200 + data = resp.json() + assert all(item["series"] == "bbc_eaw" for item in data["items"]) + slugs = [item["slug"] for item in data["items"]] + assert SLUG in slugs + + +def test_list_episodes_filter_by_series_no_match(client): + resp = client.get("/articles/episodes?series=nonexistent_series") + assert resp.status_code == 200 + data = resp.json() + assert data["count"] == 0 + assert data["items"] == [] + + +def test_get_episode_returns_series_and_source_type(client): + resp = client.get(f"/articles/episodes/{SLUG}") + assert resp.status_code == 200 + data = resp.json() + assert data["slug"] == SLUG + assert data["series"] == "bbc_eaw" + assert data["source_type"] == "article" + assert data["title"] == "Test Episode" + assert isinstance(data["segments"], list) + assert len(data["segments"]) == 1 + assert data["segments"][0]["content"] == "Hello world." + + +def test_get_episode_not_found(client): + resp = client.get("/articles/episodes/nonexistent-slug-xyz") + assert resp.status_code == 404 + + +def test_start_studying_creates_card(client): + resp = client.post(f"/articles/episodes/{SLUG}/start") + assert resp.status_code == 201 + data = resp.json() + assert data["slug"] == SLUG + assert data["title"] == "Test Episode" + assert data["due"] is not None + assert isinstance(data["id"], int) + + +def test_start_studying_not_found(client): + resp = client.post("/articles/episodes/nonexistent-slug-xyz/start") + assert resp.status_code == 404 + + +def test_rate_card(client): + # First create the card + start_resp = client.post(f"/articles/episodes/{SLUG}/start") + assert start_resp.status_code == 201 + + # Then rate it + resp = client.post( + f"/articles/episodes/{SLUG}/rate", + json={"rating": "good"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["slug"] == SLUG + assert data["review_count"] == 1 + assert data["last_reviewed_at"] is not None + assert data["due"] is not None + + +def test_rate_invalid_rating(client): + # Create card first so the route reaches the rating validation + start_resp = client.post(f"/articles/episodes/{SLUG}/start") + assert start_resp.status_code == 201 + + resp = client.post( + f"/articles/episodes/{SLUG}/rate", + json={"rating": "meh"}, + ) + assert resp.status_code == 400 + + +def test_due_returns_list(client): + resp = client.get("/articles/due") + assert resp.status_code == 200 + data = resp.json() + # /articles/due is served by the articles router (prefix="/articles", route="/due") + # which returns {"cards": [...], "count": N} + assert "cards" in data + assert "count" in data + assert isinstance(data["cards"], list) + assert data["count"] == len(data["cards"]) diff --git a/tests/test_library_smoke.py b/tests/test_library_smoke.py new file mode 100644 index 0000000..6f48683 --- /dev/null +++ b/tests/test_library_smoke.py @@ -0,0 +1,341 @@ +"""Library smoke + edge case tests + +Smoke tests: verify basic endpoint availability +Edge cases: input validation, truncation, stub-driven backfill, refetch, vocab persistence +""" +import json +import pytest +from unittest.mock import patch +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session as OrmSession + +from main import app +from app.models.db import engine, LibraryArticle, Vocabulary, init_db +from app.routers.auth import get_current_user_id, get_current_user_id_optional + +USER = "test_user_library_smoke" +client = TestClient(app) + + +# ── Fixtures ─────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def _override_user(): + app.dependency_overrides[get_current_user_id] = lambda: USER + app.dependency_overrides[get_current_user_id_optional] = lambda: USER + yield + app.dependency_overrides.pop(get_current_user_id, None) + app.dependency_overrides.pop(get_current_user_id_optional, None) + + +@pytest.fixture(autouse=True) +def _cleanup(): + init_db() + with OrmSession(engine) as s: + s.query(LibraryArticle).filter( + LibraryArticle.user_id.like("test_user_library_smoke%") + ).delete() + s.query(Vocabulary).filter( + Vocabulary.user_id.like("test_user_library_smoke%") + ).delete() + s.commit() + yield + with OrmSession(engine) as s: + s.query(LibraryArticle).filter( + LibraryArticle.user_id.like("test_user_library_smoke%") + ).delete() + s.query(Vocabulary).filter( + Vocabulary.user_id.like("test_user_library_smoke%") + ).delete() + s.commit() + + +@pytest.fixture +def stub_explain(monkeypatch): + """Stub LLM explain to avoid real API requests.""" + from app.services import explain_service + import app.routers.library as lr + + async def _fake(text, kind, user_id, context="", force=False): + return { + "explanation": {"meaning": "stub-" + text, "phonetic": "stʌb"}, + "cefr_level": "B1", + "cached": False, + } + + monkeypatch.setattr(explain_service, "explain_text", _fake) + monkeypatch.setattr(lr, "explain_text", _fake) + + +@pytest.fixture +def article_id(): + """Create a minimal article and return its id.""" + resp = client.post( + "/library/articles", + json={"markdown": "# Smoke Article\n\nTest content for smoke tests.", "title": "Smoke Article"}, + ) + assert resp.status_code == 201 + return resp.json()["id"] + + +# ── Smoke tests ──────────────────────────────────────────── + +def test_smoke_import_markdown(): + """POST /library/articles with markdown → 201.""" + resp = client.post( + "/library/articles", + json={"markdown": "# Smoke Test\n\nHello world content.", "title": "Smoke Test"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["status"] == "active" + assert data["existing"] is False + + +def test_smoke_list_empty(): + """GET /library/articles → 200 with empty articles list.""" + resp = client.get("/library/articles") + assert resp.status_code == 200 + data = resp.json() + assert "articles" in data + assert data["articles"] == [] + + +def test_smoke_get_nonexistent(): + """GET /library/articles/999 → 404.""" + resp = client.get("/library/articles/999") + assert resp.status_code == 404 + + +def test_smoke_delete_nonexistent(): + """DELETE /library/articles/999 → 404.""" + resp = client.delete("/library/articles/999") + assert resp.status_code == 404 + + +def test_smoke_phonetic(article_id): + """POST /library/articles/{id}/explain/phonetic with {text:'hello'} → 200 + phonetic field.""" + resp = client.post( + f"/library/articles/{article_id}/explain/phonetic", + json={"text": "hello"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "phonetic" in data + + +def test_smoke_explain_nonexistent_article(): + """POST /library/articles/999/explain with valid body → 404.""" + resp = client.post( + "/library/articles/999/explain", + json={"text": "hello world", "kind": "word"}, + ) + assert resp.status_code == 404 + + +# ── Edge case tests ──────────────────────────────────────── + +def test_import_both_url_and_markdown(): + """POST with both url and markdown → 201 (url takes priority per service logic).""" + fake_md = "# URL Priority\n\nContent from URL extraction." + fake_meta = {"title": "URL Priority", "site_name": "example.com", "author": None} + + with patch("app.services.library_service._extract_from_url") as mock_extract: + mock_extract.return_value = (fake_md, fake_meta) + resp = client.post( + "/library/articles", + json={ + "url": "https://example.com/url-priority", + "markdown": "# Markdown content\n\nThis should be ignored.", + "title": "Should use URL", + }, + ) + + assert resp.status_code == 201 + data = resp.json() + # URL was used — source_url populated, content from URL + assert data["source_url"] == "https://example.com/url-priority" + assert data["existing"] is False + + +def test_import_empty_body(): + """POST /library/articles with {} → 400.""" + resp = client.post("/library/articles", json={}) + assert resp.status_code == 400 + + +def test_import_empty_markdown(): + """POST with markdown='' → 400 (empty string treated as missing).""" + resp = client.post( + "/library/articles", + json={"markdown": ""}, + ) + assert resp.status_code == 400 + + +def test_import_very_long_markdown(): + """POST with markdown > 100000 chars → 201 with source_meta.truncated=true.""" + long_markdown = "# Long Article\n\n" + ("word " * 25_000) # ~125 017 chars + assert len(long_markdown) > 100_000 + + resp = client.post( + "/library/articles", + json={"markdown": long_markdown, "title": "Long Article"}, + ) + assert resp.status_code == 201 + article_id = resp.json()["id"] + + # Fetch detail to verify truncation + get_resp = client.get(f"/library/articles/{article_id}") + assert get_resp.status_code == 200 + detail = get_resp.json() + + assert len(detail["markdown"]) == 100_000 + assert detail["source_meta"].get("truncated") is True + + +def test_explain_empty_text(article_id): + """POST /library/articles/{id}/explain with text='' → 400.""" + resp = client.post( + f"/library/articles/{article_id}/explain", + json={"text": "", "kind": "word"}, + ) + assert resp.status_code == 400 + + +def test_stream_explain_empty_text(article_id): + """POST /library/articles/{id}/explain/stream with text='' → 400.""" + resp = client.post( + f"/library/articles/{article_id}/explain/stream", + json={"text": ""}, + ) + assert resp.status_code == 400 + + +def test_phonetic_empty_text(article_id): + """POST /library/articles/{id}/explain/phonetic with text='' → 400.""" + resp = client.post( + f"/library/articles/{article_id}/explain/phonetic", + json={"text": ""}, + ) + assert resp.status_code == 400 + + +def test_explain_with_stub_returns_backfilled_vocab(article_id, stub_explain): + """explain endpoint backfills explanation_json in vocabulary.""" + text = "backfill-word" + + explain_resp = client.post( + f"/library/articles/{article_id}/explain", + json={"text": text, "kind": "word"}, + ) + assert explain_resp.status_code == 200 + + from app.services import vocab_service as vs + items = vs.list_items(user_id=USER, source_type="library", source_ref=str(article_id)) + assert len(items) == 1 + item = items[0] + assert item["source_text"] == text + assert item["explanation_json"] is not None + parsed = json.loads(item["explanation_json"]) + assert parsed["meaning"] == f"stub-{text}" + + +def test_stream_explain_collects_and_backfills(article_id, monkeypatch): + """stream endpoint collects fields and backfills vocab.""" + import app.routers.library as lr + + async def fake_stream(text, user_id, force=False): + yield '{"field":"meaning","value":"test meaning"}' + yield '{"_done":true,"explanation":{"meaning":"test meaning"},"cefr_level":"B1"}' + + monkeypatch.setattr(lr, "stream_sentence_explanation", fake_stream) + + text = "stream backfill sentence" + + stream_resp = client.post( + f"/library/articles/{article_id}/explain/stream", + json={"text": text, "item_type": "sentence"}, + ) + assert stream_resp.status_code == 200 + + from app.services import vocab_service as vs + items = vs.list_items(user_id=USER, source_type="library", source_ref=str(article_id)) + assert len(items) >= 1 + match = next((i for i in items if i["source_text"] == text), None) + assert match is not None + assert match["source_type"] == "library" + assert match["source_ref"] == str(article_id) + # explanation_json should be backfilled from the stream + assert match["explanation_json"] is not None + parsed = json.loads(match["explanation_json"]) + assert parsed.get("meaning") == "test meaning" + + +def test_refetch_url_article_success(): + """POST /library/articles/{id}/refetch for URL article → 200 with updated markdown.""" + original_md = "# Original\n\nOriginal content." + updated_md = "# Updated\n\nFresh content after refetch." + + with patch("app.services.library_service._extract_from_url") as mock_extract: + mock_extract.return_value = ( + original_md, + {"title": "Original", "site_name": "example.com", "author": None}, + ) + resp = client.post( + "/library/articles", + json={"url": "https://example.com/refetch-smoke"}, + ) + + assert resp.status_code == 201 + article_id = resp.json()["id"] + + with patch("app.services.library_service._extract_from_url") as mock_extract: + mock_extract.return_value = ( + updated_md, + {"title": "Updated", "site_name": "example.com", "author": None}, + ) + refetch_resp = client.post(f"/library/articles/{article_id}/refetch") + + assert refetch_resp.status_code == 200 + data = refetch_resp.json() + assert "Fresh content after refetch" in data["markdown"] + assert data["id"] == article_id + + +def test_vocab_not_deleted_on_article_delete(): + """Delete article → vocab items with source_type='library' still exist.""" + resp = client.post( + "/library/articles", + json={"markdown": "# Vocab Persist\n\nContent.", "title": "Vocab Persist"}, + ) + assert resp.status_code == 201 + article_id = resp.json()["id"] + + # Add a vocab item tied to this article + from app.services import vocab_service as vs + vs.save_item( + user_id=USER, + source_text="persist word", + translated_text="持久化词", + direction="en2zh", + item_type="word", + source_type="library", + source_ref=str(article_id), + ) + + # Soft-delete the article + del_resp = client.delete(f"/library/articles/{article_id}") + assert del_resp.status_code == 200 + assert del_resp.json()["deleted"] is True + + # Article is gone from list + list_resp = client.get("/library/articles") + assert list_resp.status_code == 200 + ids = [a["id"] for a in list_resp.json()["articles"]] + assert article_id not in ids + + # Vocab item still exists — not cascade-deleted + items = vs.list_items(user_id=USER, source_type="library", source_ref=str(article_id)) + assert len(items) == 1 + assert items[0]["source_text"] == "persist word"