generated from kgod/ai-review-template
1023 lines
40 KiB
Python
1023 lines
40 KiB
Python
from __future__ import annotations
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from copy import deepcopy
|
|
import json
|
|
from pathlib import Path
|
|
import time
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.main import create_app
|
|
from app.fsm import assistant_turn
|
|
from app.models import Stage
|
|
from app.offerpai_auth import OfferPaiIdentity
|
|
from app.offerpai_resume import OfferPaiResumeError, SectionKind
|
|
from app.services import (
|
|
RuleBasedEntryExpander,
|
|
RuleBasedExperienceExtractor,
|
|
RuleBasedResumeRewriter,
|
|
)
|
|
from app.settings import Settings
|
|
from test_api import BASE, event
|
|
|
|
|
|
TOKEN = "header.payload.signature-value"
|
|
OTHER_TOKEN = "other.payload.signature-value"
|
|
|
|
|
|
class IdentityProvider:
|
|
def authenticate(self, token: str) -> OfferPaiIdentity:
|
|
return OfferPaiIdentity(
|
|
user_id=("other-user" if token == OTHER_TOKEN else "offerpai-user"),
|
|
mobile_number="13421012384",
|
|
nick="测试用户",
|
|
)
|
|
|
|
|
|
class ResumeProvider:
|
|
def __init__(self, default_resume_id: str = "9001") -> None:
|
|
self.default_resume_id = default_resume_id
|
|
self.main_payloads: list[dict[str, Any]] = []
|
|
self.section_payloads: list[tuple[str, str | int | None, list[dict[str, Any]]]] = []
|
|
self.deleted_ids: list[str] = []
|
|
self.remote_main: dict[str, dict[str, Any]] = {}
|
|
self.remote_sections: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
|
self.update_times: dict[str, int] = {}
|
|
self.list_calls = 0
|
|
self.get_main_calls = 0
|
|
self.list_section_calls = 0
|
|
|
|
def can_create(self, _token: str) -> bool:
|
|
return True
|
|
|
|
def list_resumes(self, _token: str) -> list[dict[str, Any]]:
|
|
self.list_calls += 1
|
|
return [
|
|
{
|
|
"id": resume_id,
|
|
"resumeName": main.get("resumeName"),
|
|
"updateTime": self.update_times.get(resume_id, 0),
|
|
}
|
|
for resume_id, main in self.remote_main.items()
|
|
]
|
|
|
|
def get_main(self, _token: str, resume_id: str | int) -> dict[str, Any]:
|
|
self.get_main_calls += 1
|
|
normalized = str(resume_id)
|
|
if normalized not in self.remote_main:
|
|
raise OfferPaiResumeError(
|
|
"offerpai_resume_not_found",
|
|
"The OfferPai resume was not found.",
|
|
status_code=404,
|
|
)
|
|
return deepcopy(self.remote_main[normalized])
|
|
|
|
def list_section(
|
|
self,
|
|
_token: str,
|
|
section: SectionKind,
|
|
*,
|
|
resume_id: str | int,
|
|
) -> list[dict[str, Any]]:
|
|
self.list_section_calls += 1
|
|
normalized = str(resume_id)
|
|
if normalized not in self.remote_main:
|
|
raise OfferPaiResumeError(
|
|
"offerpai_resume_not_found",
|
|
"The OfferPai resume was not found.",
|
|
status_code=404,
|
|
)
|
|
return deepcopy(
|
|
self.remote_sections.get(normalized, {}).get(section, [])
|
|
)
|
|
|
|
def save_main(self, _token: str, payload: Mapping[str, Any]) -> str:
|
|
body = dict(payload)
|
|
self.main_payloads.append(body)
|
|
resume_id = str(body.get("resumeId") or self.default_resume_id)
|
|
self._persist_main(resume_id, body)
|
|
return resume_id
|
|
|
|
def _persist_main(self, resume_id: str, body: Mapping[str, Any]) -> None:
|
|
stored = {key: deepcopy(value) for key, value in body.items() if key != "resumeId"}
|
|
stored["id"] = resume_id
|
|
self.remote_main[resume_id] = stored
|
|
self.remote_sections.setdefault(
|
|
resume_id,
|
|
{kind: [] for kind in ("education", "work", "internship", "project", "competition")},
|
|
)
|
|
self._touch(resume_id)
|
|
|
|
def _touch(self, resume_id: str) -> None:
|
|
self.update_times[resume_id] = self.update_times.get(resume_id, 0) + 1
|
|
|
|
def externally_update_main(self, resume_id: str = "9001", **fields: Any) -> None:
|
|
if resume_id not in self.remote_main:
|
|
raise KeyError(resume_id)
|
|
self.remote_main[resume_id].update(deepcopy(fields))
|
|
self._touch(resume_id)
|
|
|
|
def externally_replace_section(
|
|
self,
|
|
section: SectionKind,
|
|
items: Sequence[Mapping[str, Any]],
|
|
*,
|
|
resume_id: str = "9001",
|
|
) -> None:
|
|
if resume_id not in self.remote_main:
|
|
raise KeyError(resume_id)
|
|
self.remote_sections[resume_id][section] = [deepcopy(dict(item)) for item in items]
|
|
self._touch(resume_id)
|
|
|
|
def replace_section(
|
|
self,
|
|
_token: str,
|
|
section: SectionKind,
|
|
*,
|
|
resume_id: str | int | None,
|
|
items: Sequence[Mapping[str, Any]],
|
|
) -> str:
|
|
normalized = str(resume_id)
|
|
copied = [deepcopy(dict(item)) for item in items]
|
|
self.section_payloads.append((section, resume_id, copied))
|
|
stored: list[dict[str, Any]] = []
|
|
generation = self.update_times.get(normalized, 0) + 1
|
|
for index, item in enumerate(copied):
|
|
item.setdefault("id", f"{normalized}-{section}-{generation}-{index}")
|
|
stored.append(item)
|
|
self.remote_sections.setdefault(normalized, {})[section] = stored
|
|
self._touch(normalized)
|
|
return normalized
|
|
|
|
def delete_resume(self, _token: str, resume_id: str | int) -> None:
|
|
normalized = str(resume_id)
|
|
self.deleted_ids.append(normalized)
|
|
self.remote_main.pop(normalized, None)
|
|
self.remote_sections.pop(normalized, None)
|
|
self.update_times.pop(normalized, None)
|
|
|
|
|
|
class LimitedResumeProvider(ResumeProvider):
|
|
def can_create(self, _token: str) -> bool:
|
|
return False
|
|
|
|
|
|
class TimeoutAfterCreateProvider(ResumeProvider):
|
|
def __init__(self) -> None:
|
|
super().__init__("9100")
|
|
self.timed_out = False
|
|
|
|
def save_main(self, _token: str, payload: Mapping[str, Any]) -> str:
|
|
body = dict(payload)
|
|
self.main_payloads.append(body)
|
|
if "resumeId" not in body and not self.timed_out:
|
|
self.timed_out = True
|
|
self._persist_main("9100", body)
|
|
raise OfferPaiResumeError(
|
|
"offerpai_resume_timeout",
|
|
"OfferPai resume service timed out. Please try again.",
|
|
status_code=504,
|
|
)
|
|
resume_id = str(body.get("resumeId") or "9100")
|
|
self._persist_main(resume_id, body)
|
|
return resume_id
|
|
|
|
|
|
class SlowResumeProvider(ResumeProvider):
|
|
def __init__(self) -> None:
|
|
super().__init__("9200")
|
|
|
|
def save_main(self, _token: str, payload: Mapping[str, Any]) -> str:
|
|
body = dict(payload)
|
|
if "resumeId" not in body:
|
|
time.sleep(0.05)
|
|
return super().save_main(_token, body)
|
|
|
|
|
|
class MissingOnDeleteProvider(ResumeProvider):
|
|
def delete_resume(self, _token: str, _resume_id: str | int) -> None:
|
|
raise OfferPaiResumeError(
|
|
"offerpai_resume_not_found",
|
|
"The OfferPai resume or resume record was not found.",
|
|
status_code=404,
|
|
)
|
|
|
|
|
|
class NormalizingWriteResumeProvider(ResumeProvider):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.normalize_writes = False
|
|
|
|
def save_main(self, token: str, payload: Mapping[str, Any]) -> str:
|
|
resume_id = super().save_main(token, payload)
|
|
if self.normalize_writes:
|
|
self.remote_main[resume_id]["city"] = "远端规范化城市"
|
|
self._touch(resume_id)
|
|
return resume_id
|
|
|
|
|
|
class FailingReadResumeProvider(ResumeProvider):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.fail_reads = False
|
|
|
|
def list_resumes(self, token: str) -> list[dict[str, Any]]:
|
|
if self.fail_reads:
|
|
raise OfferPaiResumeError(
|
|
"offerpai_resume_timeout",
|
|
"OfferPai resume service timed out. Please try again.",
|
|
status_code=504,
|
|
)
|
|
return super().list_resumes(token)
|
|
|
|
|
|
def _client(
|
|
tmp_path: Path, resume_provider: ResumeProvider | None = None
|
|
) -> tuple[Any, TestClient, ResumeProvider]:
|
|
resume_provider = resume_provider or ResumeProvider()
|
|
application = create_app(
|
|
database_path=tmp_path / "offerpai-resume-integration.db",
|
|
cors_origins=["http://localhost:5173"],
|
|
extractor=RuleBasedExperienceExtractor(),
|
|
rewriter=RuleBasedResumeRewriter(),
|
|
expander=RuleBasedEntryExpander(),
|
|
settings=Settings(llm_provider="rule", offerpai_auth_required=True),
|
|
offerpai_identity_provider=IdentityProvider(),
|
|
offerpai_resume_provider=resume_provider,
|
|
)
|
|
return application, TestClient(application), resume_provider
|
|
|
|
|
|
def _complete_initial_collection(
|
|
client: TestClient,
|
|
headers: dict[str, str],
|
|
*,
|
|
expected_status: int = 200,
|
|
) -> tuple[str, dict[str, Any]]:
|
|
created = client.post(f"{BASE}/sessions", json={}, headers=headers)
|
|
assert created.status_code == 201, created.text
|
|
body = created.json()
|
|
session_id = body["session_id"]
|
|
|
|
response = event(
|
|
client, session_id, body, "accept", {"accepted": True}, headers=headers
|
|
)
|
|
response = event(
|
|
client,
|
|
session_id,
|
|
response.json(),
|
|
"select",
|
|
{"value": "manual"},
|
|
headers=headers,
|
|
)
|
|
response = event(
|
|
client,
|
|
session_id,
|
|
response.json(),
|
|
"select",
|
|
{"source": "account"},
|
|
headers=headers,
|
|
)
|
|
response = event(
|
|
client,
|
|
session_id,
|
|
response.json(),
|
|
"submit",
|
|
{
|
|
"name": "张三",
|
|
"email": "zhangsan@example.com",
|
|
"city": "深圳",
|
|
},
|
|
headers=headers,
|
|
)
|
|
response = event(
|
|
client,
|
|
session_id,
|
|
response.json(),
|
|
"select",
|
|
{"job_type": "campus"},
|
|
headers=headers,
|
|
)
|
|
response = event(
|
|
client,
|
|
session_id,
|
|
response.json(),
|
|
"submit",
|
|
{"target_position": "后端工程师"},
|
|
headers=headers,
|
|
)
|
|
assert response.status_code == expected_status, response.text
|
|
return session_id, response.json()
|
|
|
|
|
|
def test_initial_collection_creates_and_then_syncs_offerpai_resume(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
application, client, resume_provider = _client(tmp_path)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, ready = _complete_initial_collection(client, headers)
|
|
|
|
assert ready["stage"] == "BUILDER_CONVERSATION"
|
|
assert ready["resume_id"]
|
|
assert ready["resume"]["revision"] == 1
|
|
assert len(resume_provider.main_payloads) == 1
|
|
assert resume_provider.main_payloads[0]["name"] == "张三"
|
|
assert resume_provider.main_payloads[0]["mobileNumber"] == "13421012384"
|
|
assert resume_provider.main_payloads[0]["targetPosition"] == "后端工程师"
|
|
assert "resumeId" not in resume_provider.main_payloads[0]
|
|
assert [item[0] for item in resume_provider.section_payloads] == [
|
|
"education",
|
|
"work",
|
|
"internship",
|
|
"project",
|
|
"competition",
|
|
]
|
|
|
|
with application.state.database.transaction() as connection:
|
|
session = application.state.database.fetch_session(connection, session_id)
|
|
assert session is not None
|
|
external_resume = session["profile"]["external_resume"]
|
|
assert external_resume["id"] == "9001"
|
|
assert external_resume["status"] == "synced"
|
|
assert external_resume["synced_revision"] == 1
|
|
assert TOKEN not in json.dumps(session["profile"], ensure_ascii=False)
|
|
|
|
before = (len(resume_provider.main_payloads), len(resume_provider.section_payloads))
|
|
timeline = client.get(
|
|
f"{BASE}/sessions/{session_id}/timeline", headers=headers
|
|
)
|
|
assert timeline.status_code == 200
|
|
assert before == (
|
|
len(resume_provider.main_payloads),
|
|
len(resume_provider.section_payloads),
|
|
)
|
|
|
|
missing = client.get(f"{BASE}/sessions/{session_id}/timeline")
|
|
assert missing.status_code == 401
|
|
forbidden = client.get(
|
|
f"{BASE}/sessions/{session_id}/timeline",
|
|
headers={"Authorization": f"Bearer {OTHER_TOKEN}"},
|
|
)
|
|
assert forbidden.status_code == 403
|
|
|
|
patched = client.patch(
|
|
f"{BASE}/sessions/{session_id}/resume",
|
|
headers=headers,
|
|
json={
|
|
"expected_revision": 1,
|
|
"operation": {
|
|
"type": "update_basics",
|
|
"fields": {"city": "广州"},
|
|
},
|
|
},
|
|
)
|
|
assert patched.status_code == 200, patched.text
|
|
assert len(resume_provider.main_payloads) == 2
|
|
assert resume_provider.main_payloads[-1]["resumeId"] == "9001"
|
|
assert resume_provider.main_payloads[-1]["city"] == "广州"
|
|
|
|
deleted = client.delete(f"{BASE}/sessions/{session_id}", headers=headers)
|
|
assert deleted.status_code == 204
|
|
assert resume_provider.deleted_ids == ["9001"]
|
|
assert application.state.database.get_session(session_id) is None
|
|
|
|
|
|
def test_resume_limit_records_failed_sync_after_local_creation(tmp_path: Path) -> None:
|
|
resume_provider = LimitedResumeProvider()
|
|
application, client, _ = _client(tmp_path, resume_provider)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, error = _complete_initial_collection(
|
|
client, headers, expected_status=409
|
|
)
|
|
|
|
assert error["error"]["code"] == "offerpai_resume_limit_reached"
|
|
with application.state.database.transaction() as connection:
|
|
session = application.state.database.fetch_session(connection, session_id)
|
|
resume = application.state.database.fetch_resume(connection, session_id)
|
|
assert session is not None
|
|
assert resume is not None
|
|
assert session["stage"] == Stage.BUILDER_CONVERSATION
|
|
assert session["profile"]["external_resume"]["status"] == "failed"
|
|
assert (
|
|
session["profile"]["external_resume"]["last_error_code"]
|
|
== "offerpai_resume_limit_reached"
|
|
)
|
|
assert resume_provider.main_payloads == []
|
|
assert resume_provider.section_payloads == []
|
|
|
|
|
|
def test_timeout_after_create_reconciles_by_deterministic_name(tmp_path: Path) -> None:
|
|
resume_provider = TimeoutAfterCreateProvider()
|
|
application, client, _ = _client(tmp_path, resume_provider)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, error = _complete_initial_collection(
|
|
client, headers, expected_status=504
|
|
)
|
|
assert error["error"]["code"] == "offerpai_resume_timeout"
|
|
|
|
recovered = client.get(
|
|
f"{BASE}/sessions/{session_id}/timeline", headers=headers
|
|
)
|
|
assert recovered.status_code == 200, recovered.text
|
|
|
|
with application.state.database.transaction() as connection:
|
|
session = application.state.database.fetch_session(connection, session_id)
|
|
assert session is not None
|
|
external_resume = session["profile"]["external_resume"]
|
|
assert external_resume["id"] == "9100"
|
|
assert external_resume["status"] == "synced"
|
|
assert sum("resumeId" not in item for item in resume_provider.main_payloads) == 1
|
|
|
|
|
|
def test_delete_reconciles_uncertain_create_and_missing_remote_is_idempotent(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
uncertain_provider = TimeoutAfterCreateProvider()
|
|
application, client, _ = _client(tmp_path, uncertain_provider)
|
|
with client:
|
|
session_id, _error = _complete_initial_collection(
|
|
client, headers, expected_status=504
|
|
)
|
|
deleted = client.delete(f"{BASE}/sessions/{session_id}", headers=headers)
|
|
assert deleted.status_code == 204, deleted.text
|
|
assert uncertain_provider.deleted_ids == ["9100"]
|
|
assert application.state.database.get_session(session_id) is None
|
|
|
|
missing_provider = MissingOnDeleteProvider()
|
|
missing_root = tmp_path / "missing"
|
|
missing_root.mkdir()
|
|
second_application, second_client, _ = _client(
|
|
missing_root, missing_provider
|
|
)
|
|
with second_client:
|
|
session_id, _ready = _complete_initial_collection(second_client, headers)
|
|
deleted = second_client.delete(
|
|
f"{BASE}/sessions/{session_id}", headers=headers
|
|
)
|
|
assert deleted.status_code == 204, deleted.text
|
|
assert second_application.state.database.get_session(session_id) is None
|
|
|
|
|
|
def test_unsupported_only_change_refreshes_sync_state_without_remote_write(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
application, client, resume_provider = _client(tmp_path)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, _ready = _complete_initial_collection(client, headers)
|
|
before = (
|
|
len(resume_provider.main_payloads),
|
|
len(resume_provider.section_payloads),
|
|
)
|
|
|
|
with application.state.database.transaction(immediate=True) as connection:
|
|
resume = application.state.database.fetch_resume(connection, session_id)
|
|
assert resume is not None
|
|
content = deepcopy(resume["content"])
|
|
content.setdefault("sections", []).append(
|
|
{"kind": "campus_experience", "items": [{"name": "学生会"}]}
|
|
)
|
|
application.state.database.update_resume(connection, session_id, content)
|
|
|
|
application.state.resume_agent.sync_resume_to_offerpai(session_id, TOKEN)
|
|
with application.state.database.transaction() as connection:
|
|
partial = application.state.database.fetch_session(connection, session_id)
|
|
assert partial is not None
|
|
assert partial["profile"]["external_resume"]["status"] == "partial"
|
|
assert partial["profile"]["external_resume"]["unsupported_sections"] == [
|
|
"campus_experience"
|
|
]
|
|
assert before == (
|
|
len(resume_provider.main_payloads),
|
|
len(resume_provider.section_payloads),
|
|
)
|
|
|
|
with application.state.database.transaction(immediate=True) as connection:
|
|
resume = application.state.database.fetch_resume(connection, session_id)
|
|
assert resume is not None
|
|
content = deepcopy(resume["content"])
|
|
content["sections"] = [
|
|
section
|
|
for section in content.get("sections") or []
|
|
if section.get("kind") != "campus_experience"
|
|
]
|
|
application.state.database.update_resume(connection, session_id, content)
|
|
|
|
application.state.resume_agent.sync_resume_to_offerpai(session_id, TOKEN)
|
|
with application.state.database.transaction() as connection:
|
|
synced = application.state.database.fetch_session(connection, session_id)
|
|
assert synced is not None
|
|
assert synced["profile"]["external_resume"]["status"] == "synced"
|
|
assert synced["profile"]["external_resume"]["unsupported_sections"] == []
|
|
assert before == (
|
|
len(resume_provider.main_payloads),
|
|
len(resume_provider.section_payloads),
|
|
)
|
|
|
|
|
|
def test_concurrent_first_sync_creates_one_remote_resume_in_single_process(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
resume_provider = SlowResumeProvider()
|
|
application, client, _ = _client(tmp_path, resume_provider)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, _ready = _complete_initial_collection(client, headers)
|
|
with application.state.database.transaction(immediate=True) as connection:
|
|
session = application.state.database.fetch_session(connection, session_id)
|
|
assert session is not None
|
|
profile = deepcopy(session["profile"])
|
|
profile.pop("external_resume", None)
|
|
application.state.database.update_session(
|
|
connection,
|
|
session_id,
|
|
stage=session["stage"],
|
|
profile=profile,
|
|
increment_revision=False,
|
|
)
|
|
resume_provider.main_payloads.clear()
|
|
resume_provider.section_payloads.clear()
|
|
resume_provider.remote_main.clear()
|
|
resume_provider.remote_sections.clear()
|
|
resume_provider.update_times.clear()
|
|
|
|
agent = application.state.resume_agent
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
futures = [
|
|
executor.submit(agent.sync_resume_to_offerpai, session_id, TOKEN)
|
|
for _ in range(2)
|
|
]
|
|
assert [future.result() for future in futures] == ["9200", "9200"]
|
|
|
|
assert len(resume_provider.main_payloads) == 1
|
|
assert len(resume_provider.section_payloads) == 5
|
|
|
|
|
|
def test_resuming_legacy_minimum_ready_session_auto_creates_resume(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
application, client, resume_provider = _client(tmp_path)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
session_id = "session_legacy_minimum_ready"
|
|
application.state.database.create_session(
|
|
session_id,
|
|
Stage.MINIMUM_READY,
|
|
{
|
|
"privacy_accepted": True,
|
|
"phone": "13421012384",
|
|
"phone_source": "account",
|
|
"account_phone": "13421012384",
|
|
"name": "旧会话用户",
|
|
"email": "legacy@example.com",
|
|
"job_type": "campus",
|
|
"target_position": "后端工程师",
|
|
"anchor": {},
|
|
"experiences": [],
|
|
"external_account": OfferPaiIdentity(
|
|
user_id="offerpai-user",
|
|
mobile_number="13421012384",
|
|
nick="测试用户",
|
|
).profile_value(),
|
|
},
|
|
assistant_turn("Legacy ready session.", []),
|
|
)
|
|
|
|
with client:
|
|
resumed = client.post(f"{BASE}/sessions", json={}, headers=headers)
|
|
assert resumed.status_code == 201, resumed.text
|
|
body = resumed.json()
|
|
assert body["session_id"] == session_id
|
|
assert body["stage"] == "BUILDER_CONVERSATION"
|
|
assert body["resume_id"]
|
|
assert len(resume_provider.main_payloads) == 1
|
|
|
|
|
|
def test_external_snapshot_is_pulled_and_invalidates_stale_workflow_state(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
application, client, resume_provider = _client(tmp_path)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, _ready = _complete_initial_collection(client, headers)
|
|
with application.state.database.transaction(immediate=True) as connection:
|
|
session = application.state.database.fetch_session(connection, session_id)
|
|
resume = application.state.database.fetch_resume(connection, session_id)
|
|
assert session is not None and resume is not None
|
|
content = deepcopy(resume["content"])
|
|
content.setdefault("sections", []).append(
|
|
{
|
|
"id": "sec-campus",
|
|
"kind": "campus_experience",
|
|
"heading": "校园经历",
|
|
"items": [{"id": "entry-campus", "name": "学生会"}],
|
|
}
|
|
)
|
|
resume = application.state.database.update_resume(
|
|
connection, session_id, content
|
|
)
|
|
profile = deepcopy(session["profile"])
|
|
profile["anchor_proposal"] = {"description": "stale"}
|
|
profile.setdefault("builder", {}).update(
|
|
{
|
|
"identity_draft": {"company": "stale"},
|
|
"pending_entry": {"company": "stale"},
|
|
"editing_entry_id": "entry-stale",
|
|
"editing_base_entry": {"company": "stale"},
|
|
"selection_candidates": [{"id": "stale"}],
|
|
"revision_mode": True,
|
|
"last_confirmed_entry": {"company": "stale"},
|
|
"pending_skill_candidates": ["stale"],
|
|
"pending_summary_proposal": "stale",
|
|
}
|
|
)
|
|
application.state.database.update_session(
|
|
connection,
|
|
session_id,
|
|
stage=session["stage"],
|
|
profile=profile,
|
|
increment_revision=False,
|
|
)
|
|
application.state.database.create_optimization_run(
|
|
connection,
|
|
run_id="opt-external-stale",
|
|
session_id=session_id,
|
|
entry_id="entry-stale",
|
|
mode="light",
|
|
status="proposal_pending",
|
|
source_revision=resume["revision"],
|
|
state={"gap_report": [{"dimension": "result"}]},
|
|
proposal={"optimized_description": "stale"},
|
|
)
|
|
|
|
before_writes = (
|
|
len(resume_provider.main_payloads),
|
|
len(resume_provider.section_payloads),
|
|
)
|
|
resume_provider.externally_update_main(
|
|
resumeName="C端主简历",
|
|
city="上海",
|
|
targetPosition="平台研发工程师",
|
|
summary="专注高并发平台与工程效率建设。",
|
|
skills=["Python", "PostgreSQL"],
|
|
)
|
|
resume_provider.externally_replace_section(
|
|
"work",
|
|
[
|
|
{
|
|
"id": 9223372036854775801,
|
|
"companyName": "外部科技",
|
|
"position": "后端工程师",
|
|
"startDate": "2024-01-01",
|
|
"endDate": None,
|
|
"description": [
|
|
{"id": "remote-paragraph-1", "text": "负责核心服务重构。"}
|
|
],
|
|
}
|
|
],
|
|
)
|
|
|
|
pulled = client.get(
|
|
f"{BASE}/sessions/{session_id}/timeline", headers=headers
|
|
)
|
|
assert pulled.status_code == 200, pulled.text
|
|
body = pulled.json()
|
|
content = body["resume"]["content"]
|
|
assert content["basics"]["city"] == "上海"
|
|
assert content["target"]["position"] == "平台研发工程师"
|
|
assert content["profile_summary"]["content"] == "专注高并发平台与工程效率建设。"
|
|
assert {skill for group in content["skill_groups"] for skill in group["skills"]} == {
|
|
"Python",
|
|
"PostgreSQL",
|
|
}
|
|
work = next(
|
|
section
|
|
for section in content["sections"]
|
|
if section["kind"] == "work_experience"
|
|
)
|
|
assert work["items"][0]["company"] == "外部科技"
|
|
assert work["items"][0]["description"] == "负责核心服务重构。"
|
|
assert any(
|
|
section["kind"] == "campus_experience"
|
|
for section in content["sections"]
|
|
)
|
|
assert before_writes == (
|
|
len(resume_provider.main_payloads),
|
|
len(resume_provider.section_payloads),
|
|
)
|
|
assert not any(
|
|
block["type"] == "component" and block["lifecycle"] == "active"
|
|
for turn in body["turns"]
|
|
for block in turn["blocks"]
|
|
)
|
|
after_pull_reads = (
|
|
resume_provider.get_main_calls,
|
|
resume_provider.list_section_calls,
|
|
)
|
|
after_pull_revision = body["resume"]["revision"]
|
|
unchanged = client.get(
|
|
f"{BASE}/sessions/{session_id}/timeline", headers=headers
|
|
)
|
|
assert unchanged.status_code == 200, unchanged.text
|
|
assert unchanged.json()["resume"]["revision"] == after_pull_revision
|
|
assert after_pull_reads == (
|
|
resume_provider.get_main_calls,
|
|
resume_provider.list_section_calls,
|
|
)
|
|
assert before_writes == (
|
|
len(resume_provider.main_payloads),
|
|
len(resume_provider.section_payloads),
|
|
)
|
|
|
|
with application.state.database.transaction() as connection:
|
|
session = application.state.database.fetch_session(connection, session_id)
|
|
run = application.state.database.fetch_optimization_run(
|
|
connection, session_id, "opt-external-stale"
|
|
)
|
|
active_runs = application.state.database.list_active_optimization_runs(
|
|
connection, session_id
|
|
)
|
|
assert session is not None and run is not None
|
|
assert session["profile"]["external_resume"]["resume_name"] == "C端主简历"
|
|
assert "anchor_proposal" not in session["profile"]
|
|
builder = session["profile"]["builder"]
|
|
assert builder["identity_draft"] == {}
|
|
assert builder["pending_entry"] is None
|
|
assert builder["editing_entry_id"] is None
|
|
assert "pending_summary_proposal" not in builder
|
|
assert run["status"] == "stale"
|
|
assert run["state"]["gap_report"] == [{"dimension": "result"}]
|
|
assert active_runs == []
|
|
|
|
|
|
def test_unchanged_remote_uses_marker_without_revision_or_snapshot_reads(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
application, client, resume_provider = _client(tmp_path)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, _ready = _complete_initial_collection(client, headers)
|
|
with application.state.database.transaction() as connection:
|
|
before_session = application.state.database.fetch_session(connection, session_id)
|
|
before_resume = application.state.database.fetch_resume(connection, session_id)
|
|
assert before_session is not None and before_resume is not None
|
|
before_reads = (
|
|
resume_provider.get_main_calls,
|
|
resume_provider.list_section_calls,
|
|
)
|
|
|
|
for _ in range(3):
|
|
response = client.get(
|
|
f"{BASE}/sessions/{session_id}/timeline", headers=headers
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
|
|
with application.state.database.transaction() as connection:
|
|
after_session = application.state.database.fetch_session(connection, session_id)
|
|
after_resume = application.state.database.fetch_resume(connection, session_id)
|
|
assert after_session is not None and after_resume is not None
|
|
assert after_session["revision"] == before_session["revision"]
|
|
assert after_resume["revision"] == before_resume["revision"]
|
|
assert before_reads == (
|
|
resume_provider.get_main_calls,
|
|
resume_provider.list_section_calls,
|
|
)
|
|
|
|
|
|
def test_external_section_edit_preserves_local_entry_id(tmp_path: Path) -> None:
|
|
application, client, resume_provider = _client(tmp_path)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, _ready = _complete_initial_collection(client, headers)
|
|
with application.state.database.transaction(immediate=True) as connection:
|
|
resume = application.state.database.fetch_resume(connection, session_id)
|
|
assert resume is not None
|
|
content = deepcopy(resume["content"])
|
|
content.setdefault("sections", []).append(
|
|
{
|
|
"id": "sec-work-local",
|
|
"kind": "work_experience",
|
|
"heading": "工作经历",
|
|
"items": [
|
|
{
|
|
"id": "entry-work-stable",
|
|
"company": "稳定科技",
|
|
"position": "后端工程师",
|
|
"start_date": "2023-01",
|
|
"end_date_or_present": "present",
|
|
"description": "负责订单服务。",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
application.state.database.update_resume(connection, session_id, content)
|
|
application.state.resume_agent.sync_resume_to_offerpai(session_id, TOKEN)
|
|
|
|
remote_item = deepcopy(resume_provider.remote_sections["9001"]["work"][0])
|
|
remote_item["description"][0]["text"] = "负责订单服务并将延迟降低 30%。"
|
|
resume_provider.externally_replace_section("work", [remote_item])
|
|
|
|
pulled = client.get(
|
|
f"{BASE}/sessions/{session_id}/timeline", headers=headers
|
|
)
|
|
assert pulled.status_code == 200, pulled.text
|
|
work = next(
|
|
section
|
|
for section in pulled.json()["resume"]["content"]["sections"]
|
|
if section["kind"] == "work_experience"
|
|
)
|
|
assert work["items"][0]["id"] == "entry-work-stable"
|
|
assert work["items"][0]["description"] == "负责订单服务并将延迟降低 30%。"
|
|
|
|
|
|
def test_external_change_before_patch_pulls_then_returns_revision_conflict(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
application, client, resume_provider = _client(tmp_path)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, ready = _complete_initial_collection(client, headers)
|
|
original_revision = ready["resume"]["revision"]
|
|
before_writes = len(resume_provider.main_payloads)
|
|
resume_provider.externally_update_main(city="杭州")
|
|
|
|
patched = client.patch(
|
|
f"{BASE}/sessions/{session_id}/resume",
|
|
headers=headers,
|
|
json={
|
|
"expected_revision": original_revision,
|
|
"operation": {
|
|
"type": "update_basics",
|
|
"fields": {"city": "广州"},
|
|
},
|
|
},
|
|
)
|
|
assert patched.status_code == 409, patched.text
|
|
assert patched.json()["error"]["code"] == "revision_conflict"
|
|
assert len(resume_provider.main_payloads) == before_writes
|
|
with application.state.database.transaction() as connection:
|
|
resume = application.state.database.fetch_resume(connection, session_id)
|
|
assert resume is not None
|
|
assert resume["content"]["basics"]["city"] == "杭州"
|
|
|
|
|
|
def test_non_revision_mutation_stops_after_external_pull(tmp_path: Path) -> None:
|
|
application, client, resume_provider = _client(tmp_path)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, _ready = _complete_initial_collection(client, headers)
|
|
before_writes = len(resume_provider.main_payloads)
|
|
resume_provider.externally_update_main(city="苏州")
|
|
|
|
changed = client.post(
|
|
f"{BASE}/sessions/{session_id}/target-position",
|
|
headers=headers,
|
|
json={"target_position": "客户端工程师"},
|
|
)
|
|
assert changed.status_code == 409, changed.text
|
|
assert changed.json()["error"]["code"] == "offerpai_resume_changed"
|
|
assert len(resume_provider.main_payloads) == before_writes
|
|
with application.state.database.transaction() as connection:
|
|
session = application.state.database.fetch_session(connection, session_id)
|
|
assert session is not None
|
|
assert session["profile"]["target_position"] == "后端工程师"
|
|
|
|
|
|
def test_mutation_fails_closed_when_remote_cannot_be_read(tmp_path: Path) -> None:
|
|
resume_provider = FailingReadResumeProvider()
|
|
application, client, _ = _client(tmp_path, resume_provider)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, _ready = _complete_initial_collection(client, headers)
|
|
before_writes = len(resume_provider.main_payloads)
|
|
resume_provider.fail_reads = True
|
|
|
|
failed = client.post(
|
|
f"{BASE}/sessions/{session_id}/target-position",
|
|
headers=headers,
|
|
json={"target_position": "客户端工程师"},
|
|
)
|
|
assert failed.status_code == 504, failed.text
|
|
assert failed.json()["error"]["code"] == "offerpai_resume_timeout"
|
|
assert len(resume_provider.main_payloads) == before_writes
|
|
with application.state.database.transaction() as connection:
|
|
session = application.state.database.fetch_session(connection, session_id)
|
|
assert session is not None
|
|
assert session["profile"]["target_position"] == "后端工程师"
|
|
|
|
|
|
def test_push_is_not_marked_synced_when_remote_roundtrip_differs(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
resume_provider = NormalizingWriteResumeProvider()
|
|
application, client, _ = _client(tmp_path, resume_provider)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, ready = _complete_initial_collection(client, headers)
|
|
resume_provider.normalize_writes = True
|
|
|
|
patched = client.patch(
|
|
f"{BASE}/sessions/{session_id}/resume",
|
|
headers=headers,
|
|
json={
|
|
"expected_revision": ready["resume"]["revision"],
|
|
"operation": {
|
|
"type": "update_basics",
|
|
"fields": {"city": "广州"},
|
|
},
|
|
},
|
|
)
|
|
|
|
assert patched.status_code == 409, patched.text
|
|
assert patched.json()["error"]["code"] == "offerpai_resume_conflict"
|
|
assert resume_provider.remote_main["9001"]["city"] == "远端规范化城市"
|
|
with application.state.database.transaction() as connection:
|
|
session = application.state.database.fetch_session(connection, session_id)
|
|
resume = application.state.database.fetch_resume(connection, session_id)
|
|
assert session is not None and resume is not None
|
|
assert resume["content"]["basics"]["city"] == "广州"
|
|
external_resume = session["profile"]["external_resume"]
|
|
assert external_resume["status"] == "conflict"
|
|
assert external_resume["payload_hash_version"] == 2
|
|
|
|
|
|
def test_both_sides_changed_returns_conflict_without_remote_write(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
application, client, resume_provider = _client(tmp_path)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, _ready = _complete_initial_collection(client, headers)
|
|
with application.state.database.transaction(immediate=True) as connection:
|
|
resume = application.state.database.fetch_resume(connection, session_id)
|
|
assert resume is not None
|
|
content = deepcopy(resume["content"])
|
|
content["basics"]["city"] = "广州"
|
|
application.state.database.update_resume(connection, session_id, content)
|
|
resume_provider.externally_update_main(targetPosition="数据平台工程师")
|
|
before_writes = (
|
|
len(resume_provider.main_payloads),
|
|
len(resume_provider.section_payloads),
|
|
)
|
|
|
|
conflicted = client.get(
|
|
f"{BASE}/sessions/{session_id}/timeline", headers=headers
|
|
)
|
|
assert conflicted.status_code == 409, conflicted.text
|
|
assert conflicted.json()["error"]["code"] == "offerpai_resume_conflict"
|
|
assert before_writes == (
|
|
len(resume_provider.main_payloads),
|
|
len(resume_provider.section_payloads),
|
|
)
|
|
with application.state.database.transaction() as connection:
|
|
session = application.state.database.fetch_session(connection, session_id)
|
|
resume = application.state.database.fetch_resume(connection, session_id)
|
|
assert session is not None and resume is not None
|
|
assert session["profile"]["external_resume"]["status"] == "conflict"
|
|
assert resume["content"]["basics"]["city"] == "广州"
|
|
|
|
|
|
def test_remote_deletion_is_not_recreated_by_timeline_read(tmp_path: Path) -> None:
|
|
application, client, resume_provider = _client(tmp_path)
|
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
|
|
|
with client:
|
|
session_id, _ready = _complete_initial_collection(client, headers)
|
|
resume_provider.remote_main.clear()
|
|
resume_provider.remote_sections.clear()
|
|
resume_provider.update_times.clear()
|
|
before_writes = len(resume_provider.main_payloads)
|
|
|
|
for _ in range(2):
|
|
missing = client.get(
|
|
f"{BASE}/sessions/{session_id}/timeline", headers=headers
|
|
)
|
|
assert missing.status_code == 404, missing.text
|
|
assert missing.json()["error"]["code"] == "offerpai_resume_not_found"
|
|
assert len(resume_provider.main_payloads) == before_writes
|
|
with application.state.database.transaction() as connection:
|
|
session = application.state.database.fetch_session(connection, session_id)
|
|
assert session is not None
|
|
external_resume = session["profile"]["external_resume"]
|
|
assert external_resume["id"] == "9001"
|
|
assert external_resume["status"] == "remote_deleted"
|