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", {"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"