- {{ new Date(entry.timestamp).toLocaleString('ko-KR') }}
+
+ {{ new Date(entry.updated_at).toLocaleString('ko-KR') }}
+ 보관됨
-
+
{{ entry.title }}
+
{{ entry.source_preview || '(빈 원문)' }}
-
→ {{ entry.target_language }}
+
Phase {{ entry.current_phase }} · → {{ entry.target_language }}
+
+
+
+
+
+
diff --git a/tests/test_backend.py b/tests/test_backend.py
index 661a213..002767c 100644
--- a/tests/test_backend.py
+++ b/tests/test_backend.py
@@ -1,19 +1,43 @@
import json
+import tempfile
import unittest
+from pathlib import Path
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
-from backend.auth import create_access_token
+from backend.auth import create_access_token, get_current_user, require_admin
+from backend.database import Database, LastAdminError
from backend.llm_client import get_llm_configs
from backend.main import _split_text, app
from backend.models import TranslationSession
-from backend.sessions import SessionConflictError, SessionStore, session_store
+from backend.sessions import (
+ SessionArchivedError,
+ SessionConflictError,
+ SessionStore,
+ session_store,
+)
class ApiTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.temp_dir = tempfile.TemporaryDirectory()
+ cls.db = Database(Path(cls.temp_dir.name) / "api.db")
+ cls.db.create_user("admin", "unused", is_admin=True)
+ session_store.db = cls.db
+ app.dependency_overrides[get_current_user] = lambda: "admin"
+
+ @classmethod
+ def tearDownClass(cls):
+ app.dependency_overrides.clear()
+ cls.temp_dir.cleanup()
+
def setUp(self):
- session_store._store.clear()
+ with self.db.connect() as connection:
+ connection.execute("DELETE FROM session_revisions")
+ connection.execute("DELETE FROM translation_sessions")
+ session_store._progress.clear()
token = create_access_token({"user_id": "admin"})
self.client = TestClient(app, headers={"Authorization": f"Bearer {token}"})
self.model = get_llm_configs()[0].alias
@@ -35,9 +59,13 @@ class ApiTests(unittest.TestCase):
return response.json()["session_id"]
def test_translation_routes_require_authentication(self):
- response = TestClient(app).post(
- "/api/translate/create", json={"source_text": "Hello"}
- )
+ override = app.dependency_overrides.pop(get_current_user)
+ try:
+ response = TestClient(app).post(
+ "/api/translate/create", json={"source_text": "Hello"}
+ )
+ finally:
+ app.dependency_overrides[get_current_user] = override
self.assertEqual(response.status_code, 401)
def test_progress_endpoint_returns_only_transient_progress(self):
@@ -89,28 +117,25 @@ class ApiTests(unittest.TestCase):
)
responses = [phase1, "다시 번역", "최종 번역"]
with patch("backend.main.chat_complete", new=AsyncMock(side_effect=responses)):
- self.assertEqual(
- self.client.post(f"/api/translate/{session_id}/phase1").status_code,
- 200,
- )
- self.assertEqual(
- self.client.post(
- f"/api/translate/{session_id}/phase2",
- json={"proper_nouns": [], "style": "중립적"},
- ).status_code,
- 200,
- )
- self.assertEqual(
- self.client.post(f"/api/translate/{session_id}/phase3").status_code,
- 200,
- )
- self.assertEqual(
- self.client.post(f"/api/translate/{session_id}/phase4").status_code,
- 200,
+ response = self.client.post(f"/api/translate/{session_id}/phase1")
+ self.assertEqual(response.status_code, 200)
+ response = self.client.post(
+ f"/api/translate/{session_id}/phase2",
+ json={
+ "proper_nouns": [],
+ "style": "중립적",
+ "expected_version": response.json()["version"],
+ },
)
+ self.assertEqual(response.status_code, 200)
+ response = self.client.post(f"/api/translate/{session_id}/phase3")
+ self.assertEqual(response.status_code, 200)
+ response = self.client.post(f"/api/translate/{session_id}/phase4")
+ self.assertEqual(response.status_code, 200)
response = self.client.patch(
- f"/api/sessions/{session_id}", json={"source_text": "Changed"}
+ f"/api/sessions/{session_id}",
+ json={"source_text": "Changed", "expected_version": response.json()["version"]},
)
self.assertEqual(response.status_code, 200)
data = response.json()["data"]
@@ -147,6 +172,47 @@ class ApiTests(unittest.TestCase):
self.assertEqual(response.json()["data"]["phase1_result"]["translated"], "안녕하세요")
self.assertEqual(mocked.await_count, 1)
+ def test_phase2_rejects_stale_version(self):
+ session_id = self.create_session()
+ phase1 = json.dumps(
+ {
+ "translated": "안녕하세요",
+ "proper_nouns": [],
+ "summary": "",
+ "style": "",
+ },
+ ensure_ascii=False,
+ )
+ with patch("backend.main.chat_complete", new=AsyncMock(return_value=phase1)):
+ response = self.client.post(f"/api/translate/{session_id}/phase1")
+ stale_version = response.json()["version"]
+ session_store.update(session_id, {"phase2_style": "newer"}, "admin")
+
+ response = self.client.post(
+ f"/api/translate/{session_id}/phase2",
+ json={
+ "proper_nouns": [],
+ "style": "stale",
+ "expected_version": stale_version,
+ },
+ )
+ self.assertEqual(response.status_code, 409)
+
+ def test_archived_session_rejects_api_patch(self):
+ session_id = self.create_session()
+ response = self.client.get(f"/api/sessions/{session_id}")
+ version = response.json()["version"]
+ self.assertEqual(
+ self.client.post(f"/api/sessions/{session_id}/archive").status_code,
+ 200,
+ )
+
+ response = self.client.patch(
+ f"/api/sessions/{session_id}",
+ json={"source_text": "changed", "expected_version": version + 1},
+ )
+ self.assertEqual(response.status_code, 409)
+
def test_long_phase1_is_split_into_bounded_chunks(self):
source_text = "这是用于测试长文本分割的句子。" * 120
expected_chunks = _split_text(source_text)
@@ -170,10 +236,15 @@ class ApiTests(unittest.TestCase):
self.assertEqual(mocked.await_count, len(expected_chunks))
self.assertEqual(len(response.json()["data"]["phase1_chunks"]), len(expected_chunks))
- self.client.post(
+ response = self.client.post(
f"/api/translate/{session_id}/phase2",
- json={"proper_nouns": [], "style": "중립적"},
+ json={
+ "proper_nouns": [],
+ "style": "중립적",
+ "expected_version": response.json()["version"],
+ },
)
+ self.assertEqual(response.status_code, 200)
phase3_mock = AsyncMock(side_effect=["재번역 조각"] * len(expected_chunks))
with patch("backend.main.chat_complete", new=phase3_mock):
response = self.client.post(f"/api/translate/{session_id}/phase3")
@@ -201,8 +272,18 @@ class ApiTests(unittest.TestCase):
class SessionStoreTests(unittest.TestCase):
+ def setUp(self):
+ self.temp_dir = tempfile.TemporaryDirectory()
+ self.db = Database(Path(self.temp_dir.name) / "store.db")
+ self.db.create_user("alice", "unused")
+ self.db.create_user("bob", "unused")
+ self.store = SessionStore(self.db)
+
+ def tearDown(self):
+ self.temp_dir.cleanup()
+
def test_sessions_are_isolated_by_owner(self):
- store = SessionStore()
+ store = self.store
session_id = store.create(TranslationSession(source_text="secret"), "alice")
self.assertIsNotNone(store.get(session_id, "alice"))
@@ -212,7 +293,7 @@ class SessionStoreTests(unittest.TestCase):
self.assertEqual(store.get(session_id, "alice").source_text, "secret")
def test_stale_phase_update_is_rejected(self):
- store = SessionStore()
+ store = self.store
session_id = store.create(TranslationSession(source_text="first"), "alice")
_, version = store.get_with_version(session_id, "alice")
store.update(session_id, {"source_text": "second"}, "alice")
@@ -226,7 +307,7 @@ class SessionStoreTests(unittest.TestCase):
)
def test_progress_does_not_change_session_version(self):
- store = SessionStore()
+ store = self.store
session_id = store.create(TranslationSession(source_text="first"), "alice")
_, version = store.get_with_version(session_id, "alice")
@@ -247,6 +328,135 @@ class SessionStoreTests(unittest.TestCase):
self.assertEqual(current_version, version)
self.assertEqual(session.progress.preview, "부분 결과")
+ def test_session_persists_across_store_instances(self):
+ session_id = self.store.create(
+ TranslationSession(source_text="persistent text"), "alice"
+ )
+
+ reopened_store = SessionStore(Database(self.db.path))
+ reopened = reopened_store.get(session_id, "alice")
+ self.assertIsNotNone(reopened)
+ self.assertEqual(reopened.source_text, "persistent text")
+
+ def test_previous_revision_can_be_restored(self):
+ session_id = self.store.create(TranslationSession(source_text="first"), "alice")
+ self.store.update(
+ session_id,
+ {
+ "phase1_result": {
+ "translated": "첫 번역",
+ "proper_nouns": [],
+ "summary": "",
+ "style": "",
+ }
+ },
+ "alice",
+ current_phase=1,
+ )
+ self.store.update(
+ session_id,
+ {"source_text": "second", "phase1_result": None},
+ "alice",
+ snapshot_reason="session_edit",
+ current_phase=0,
+ )
+
+ revision = self.store.get_revision(session_id, "alice")
+ self.assertEqual(revision["data"]["source_text"], "first")
+ self.assertTrue(self.store.restore_revision(session_id, "alice"))
+ restored = self.store.get(session_id, "alice")
+ self.assertEqual(restored.source_text, "first")
+ self.assertEqual(restored.phase1_result.translated, "첫 번역")
+
+ def test_archive_clone_and_list_are_owner_scoped(self):
+ session_id = self.store.create(TranslationSession(source_text="Alice text"), "alice")
+ self.store.create(TranslationSession(source_text="Bob text"), "bob")
+
+ self.assertEqual(len(self.store.list("alice")), 1)
+ self.assertTrue(self.store.archive(session_id, "alice"))
+ self.assertEqual(self.store.list("alice"), [])
+ self.assertEqual(len(self.store.list("alice", include_archived=True)), 1)
+ clone_id = self.store.clone(session_id, "alice")
+ self.assertIsNotNone(clone_id)
+ self.assertIsNone(self.store.get(clone_id, "bob"))
+
+ def test_archived_session_rejects_content_updates(self):
+ session_id = self.store.create(TranslationSession(source_text="locked"), "alice")
+ _, version = self.store.get_with_version(session_id, "alice")
+ self.assertTrue(self.store.archive(session_id, "alice"))
+
+ with self.assertRaises(SessionArchivedError):
+ self.store.update(
+ session_id,
+ {"source_text": "changed"},
+ "alice",
+ expected_version=version + 1,
+ )
+
+ def test_clone_preserves_completed_phase_metadata(self):
+ session_id = self.store.create(TranslationSession(source_text="complete"), "alice")
+ self.store.update(
+ session_id,
+ {"phase4_result": "final"},
+ "alice",
+ current_phase=4,
+ )
+
+ clone_id = self.store.clone(session_id, "alice")
+ summary = next(item for item in self.store.list("alice") if item["session_id"] == clone_id)
+ self.assertEqual(summary["current_phase"], 4)
+ self.assertEqual(summary["status"], "completed")
+
+
+class UserDatabaseTests(unittest.TestCase):
+ def test_password_change_increments_token_version(self):
+ with tempfile.TemporaryDirectory() as directory:
+ db = Database(Path(directory) / "users.db")
+ db.create_user("admin", "first", is_admin=True)
+ before = db.get_user("admin")
+ self.assertTrue(db.update_user("admin", password_hash="second"))
+ after = db.get_user("admin")
+ self.assertEqual(after["token_version"], before["token_version"] + 1)
+
+ def test_last_active_admin_cannot_be_removed(self):
+ with tempfile.TemporaryDirectory() as directory:
+ db = Database(Path(directory) / "users.db")
+ db.create_user("admin", "first", is_admin=True)
+
+ with self.assertRaises(LastAdminError):
+ db.update_user("admin", is_active=False)
+
+
+class AdminApiTests(unittest.TestCase):
+ def setUp(self):
+ self.temp_dir = tempfile.TemporaryDirectory()
+ self.db = Database(Path(self.temp_dir.name) / "admin.db")
+ self.db.create_user("admin", "unused", is_admin=True)
+ self.database_patch = patch("backend.main.database", self.db)
+ self.database_patch.start()
+ app.dependency_overrides[require_admin] = lambda: "admin"
+ self.client = TestClient(app)
+
+ def tearDown(self):
+ app.dependency_overrides.pop(require_admin, None)
+ self.database_patch.stop()
+ self.temp_dir.cleanup()
+
+ def test_admin_can_create_and_deactivate_user(self):
+ response = self.client.post(
+ "/api/admin/users",
+ json={"id": "translator", "password": "secure-pass", "is_admin": False},
+ )
+ self.assertEqual(response.status_code, 201)
+ users = self.client.get("/api/admin/users").json()
+ self.assertEqual({user["id"] for user in users}, {"admin", "translator"})
+
+ response = self.client.patch(
+ "/api/admin/users/translator", json={"is_active": False}
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertFalse(self.db.get_user("translator")["is_active"])
+
if __name__ == "__main__":
unittest.main()