Files
resume-agent/backend/tests/test_offerpai_resume_integration.py
T

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