fix: advance imported rectification profile pointer
This commit is contained in:
@@ -219,7 +219,6 @@ export async function loadProductionConversationalRectificationProfile(
|
||||
const protocol = prior ? text(prior.journey_protocol) : null;
|
||||
const status = prior ? text(prior.status) : null;
|
||||
const unfinishedLegacyStatuses = new Set([
|
||||
"reported",
|
||||
"assessing",
|
||||
"rectifying",
|
||||
"candidate",
|
||||
|
||||
@@ -20,9 +20,6 @@ const unfinishedLegacyStatuses = new Set([
|
||||
"rectifying",
|
||||
"candidate",
|
||||
"confirming",
|
||||
"starting",
|
||||
"active",
|
||||
"paused",
|
||||
]);
|
||||
|
||||
export type LegacyConversationalImportSource = Readonly<{
|
||||
|
||||
+12
-1
@@ -210,7 +210,7 @@ begin
|
||||
for update;
|
||||
if not found
|
||||
or v_legacy.journey_protocol not in ('legacy-guided-v1', 'dynamic-choice-v2')
|
||||
or v_legacy.status in ('confirmed', 'completed', 'abandoned') then
|
||||
or v_legacy.status not in ('assessing', 'rectifying', 'candidate', 'confirming') then
|
||||
raise exception 'conversational_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_legacy.turn_version is distinct from p_expected_version then
|
||||
@@ -243,6 +243,9 @@ begin
|
||||
if not found then
|
||||
raise exception 'conversational_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_profile.rectification_case_id is distinct from p_legacy_case_id then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
v_profile.credits := public.recover_conversational_rectification_orphan_reservations(
|
||||
p_user_id, null::uuid
|
||||
);
|
||||
@@ -375,6 +378,14 @@ begin
|
||||
p_legacy_case_id, v_profile.active_birth_time,
|
||||
nullif(p_pending_consultation_question, ''), pg_catalog.now()
|
||||
);
|
||||
update public.profiles
|
||||
set rectification_case_id = p_case_id,
|
||||
updated_at = pg_catalog.now()
|
||||
where id = p_user_id
|
||||
and rectification_case_id = p_legacy_case_id;
|
||||
if not found then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
insert into public.birth_time_rectification_turns (
|
||||
case_id, turn_version, narrative, candidate, technical_receipt,
|
||||
evidence_request, evidence_recap, actions, output_validation_receipt
|
||||
|
||||
@@ -140,6 +140,10 @@ test("v1 and v2 projection preserves only declared facts, latest range, and scor
|
||||
|
||||
test("projection rejects terminal, foreign-owner, and unsupported protocol sources", () => {
|
||||
for (const candidate of [
|
||||
{ ...source("legacy-guided-v1"), status: "reported" },
|
||||
{ ...source("dynamic-choice-v2"), status: "starting" },
|
||||
{ ...source("dynamic-choice-v2"), status: "active" },
|
||||
{ ...source("dynamic-choice-v2"), status: "paused" },
|
||||
{ ...source("legacy-guided-v1"), status: "completed" },
|
||||
{ ...source("dynamic-choice-v2"), status: "abandoned" },
|
||||
{ ...source("dynamic-choice-v2"), status: "confirmed" },
|
||||
@@ -155,6 +159,17 @@ test("projection rejects terminal, foreign-owner, and unsupported protocol sourc
|
||||
}
|
||||
});
|
||||
|
||||
test("projection accepts exactly the four legal unfinished legacy case statuses", () => {
|
||||
for (const status of ["assessing", "rectifying", "candidate", "confirming"]) {
|
||||
const projected = projectLegacyCaseForConversationalImport({
|
||||
source: { ...source("dynamic-choice-v2"), status },
|
||||
asOfDate: "2026-07-21",
|
||||
expectedUserId: userId,
|
||||
});
|
||||
assert.equal(projected.legacyCaseId, legacyCaseId);
|
||||
}
|
||||
});
|
||||
|
||||
function importedRow(input: {
|
||||
readonly firstTurn: ConversationalRectificationTurnInput;
|
||||
readonly privateCandidate: NonNullable<LoadedConversationalRectificationCase["privateCandidate"]>;
|
||||
|
||||
@@ -331,8 +331,14 @@ test("production profile conversion links terminal v3 revisions and owner-bound
|
||||
[{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "completed" }, priorId, null],
|
||||
[{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "abandoned" }, priorId, null],
|
||||
[{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "active" }, null, null],
|
||||
[{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "assessing" }, null, priorId],
|
||||
[{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "rectifying" }, null, priorId],
|
||||
[{ id: priorId, journey_protocol: "legacy-guided-v1", status: "candidate" }, null, priorId],
|
||||
[{ id: priorId, journey_protocol: "legacy-guided-v1", status: "confirming" }, null, priorId],
|
||||
[{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "reported" }, null, null],
|
||||
[{ id: priorId, journey_protocol: "legacy-guided-v1", status: "starting" }, null, null],
|
||||
[{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "active" }, null, null],
|
||||
[{ id: priorId, journey_protocol: "legacy-guided-v1", status: "paused" }, null, null],
|
||||
[{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "confirmed" }, null, null],
|
||||
[{ id: priorId, journey_protocol: "legacy-guided-v1", status: "abandoned" }, null, null],
|
||||
[{ id: priorId, journey_protocol: "dynamic-choice-v2", status: null }, null, null],
|
||||
@@ -405,6 +411,44 @@ test("legacy import declaration uses the immutable old case time while preservin
|
||||
});
|
||||
});
|
||||
|
||||
test("an abandoned imported v3 profile pointer becomes the paid revision base", async () => {
|
||||
const importedCaseId = "00000000-0000-4000-8000-000000000120";
|
||||
const importedFromCaseId = "00000000-0000-4000-8000-000000000121";
|
||||
const loaded = await loadProductionConversationalRectificationProfile({
|
||||
async loadProfile() {
|
||||
return {
|
||||
birth_date: "1990-01-01",
|
||||
reported_birth_time: "05:20:00",
|
||||
active_birth_time: "04:58:00",
|
||||
birth_time_source: "legacy_import",
|
||||
birth_time_period: null,
|
||||
birth_time_clue: "现存账户线索",
|
||||
uncertainty_before_minutes: 0,
|
||||
uncertainty_after_minutes: 0,
|
||||
country_code: "TW",
|
||||
province_code: "TPE",
|
||||
city_code: "TPE-CITY",
|
||||
district_code: "DAAN",
|
||||
latitude: 25.0268,
|
||||
longitude: 121.5434,
|
||||
timezone_offset: 8,
|
||||
rectification_case_id: importedCaseId,
|
||||
};
|
||||
},
|
||||
async loadRectificationCase() {
|
||||
return {
|
||||
id: importedCaseId,
|
||||
journey_protocol: "conversational-evidence-v3",
|
||||
status: "abandoned",
|
||||
imported_from_case_id: importedFromCaseId,
|
||||
};
|
||||
},
|
||||
}, userId);
|
||||
|
||||
assert.equal(loaded.revisionOfCaseId, importedCaseId);
|
||||
assert.equal(loaded.legacyCaseId, null);
|
||||
});
|
||||
|
||||
test("production unknown-time adapter covers the declared full day with bounded deduplicated scans", async () => {
|
||||
const scanCalls: Array<{ birthTime: string; uncertaintyMinutes: number }> = [];
|
||||
const minute = (value: string) => {
|
||||
|
||||
@@ -235,7 +235,10 @@ def test_forward_legacy_import_projects_only_trusted_facts_into_v3() -> None:
|
||||
assert "set credits =" not in body
|
||||
assert "for update" in body
|
||||
assert "pg_advisory_xact_lock" in body
|
||||
assert "v_legacy.status in ('confirmed', 'completed', 'abandoned')" in body
|
||||
assert "v_legacy.status not in ('assessing', 'rectifying', 'candidate', 'confirming')" in body
|
||||
assert "v_profile.rectification_case_id is distinct from p_legacy_case_id" in body
|
||||
assert "rectification_case_id = p_case_id" in body
|
||||
assert "rectification_case_id = p_legacy_case_id" in body
|
||||
assert "v_legacy.turn_version is distinct from p_expected_version" in body
|
||||
assert "current_date" in body
|
||||
|
||||
|
||||
@@ -286,6 +286,7 @@ def _create_case(
|
||||
action_id: str,
|
||||
declared_birth_input: dict[str, object],
|
||||
private_candidate: dict[str, object] | None = None,
|
||||
revision_of_case_id: str | None = None,
|
||||
) -> str:
|
||||
return database.sql(
|
||||
f"""
|
||||
@@ -294,7 +295,7 @@ def _create_case(
|
||||
'{action_id}'::uuid,
|
||||
0,
|
||||
'{action_id}'::uuid,
|
||||
null,
|
||||
{"null" if revision_of_case_id is None else f"'{revision_of_case_id}'::uuid"},
|
||||
null,
|
||||
{_jsonb(declared_birth_input)},
|
||||
{_jsonb(_valid_turn(action_id))},
|
||||
@@ -361,6 +362,9 @@ def _create_legacy_case(database: PgDatabase, user_id: str, legacy_case_id: str)
|
||||
'{legacy_case_id}'::uuid, '{user_id}'::uuid, 'rectifying',
|
||||
'1990-01-01', '05:20', 'legacy_import', 0, 0, 'legacy-guided-v1'
|
||||
);
|
||||
update public.profiles
|
||||
set rectification_case_id = '{legacy_case_id}'::uuid
|
||||
where id = '{user_id}'::uuid;
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -2069,3 +2073,187 @@ def test_concurrent_legacy_import_projects_events_once_and_keeps_old_row_read_on
|
||||
assert pg14_database.rejects(
|
||||
f"update public.birth_time_rectification_cases set answers = '{{}}'::jsonb where id = '{legacy_case_id}'::uuid"
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_import_pointer_cas_drift_rolls_back_every_import_artifact(
|
||||
pg14_database: PgDatabase,
|
||||
) -> None:
|
||||
user_id = "00000000-0000-4000-8000-000000002721"
|
||||
legacy_case_id = "00000000-0000-4000-8000-000000002722"
|
||||
action_id = "00000000-0000-4000-8000-000000002723"
|
||||
_create_user(pg14_database, user_id, credits=10)
|
||||
_create_legacy_case(pg14_database, user_id, legacy_case_id)
|
||||
pg14_database.sql(
|
||||
f"update public.profiles set rectification_case_id = null where id = '{user_id}'::uuid"
|
||||
)
|
||||
declared = {
|
||||
"birthDate": "1990-01-01",
|
||||
"reportedTime": "05:20",
|
||||
"source": "legacy_import",
|
||||
"birthTimeClue": None,
|
||||
"uncertaintyBeforeMinutes": 0,
|
||||
"uncertaintyAfterMinutes": 0,
|
||||
"birthplace": {
|
||||
"countryCode": "TW",
|
||||
"provinceCode": "TPE",
|
||||
"cityCode": "TPE-CITY",
|
||||
"districtCode": "DAAN",
|
||||
"latitude": 25.0268,
|
||||
"longitude": 121.5434,
|
||||
"timezoneOffset": 8,
|
||||
},
|
||||
}
|
||||
statement = f"""
|
||||
select public.import_legacy_conversational_rectification_case(
|
||||
'{user_id}'::uuid, '{action_id}'::uuid, '{legacy_case_id}'::uuid,
|
||||
0, '{action_id}'::uuid, 3, null,
|
||||
{_jsonb(declared)}, '[]'::jsonb,
|
||||
{_jsonb(_valid_turn(action_id))},
|
||||
{_jsonb({'modelId': 'synthetic-model', 'schemaValidated': True})},
|
||||
{_jsonb(_valid_private_candidate())}
|
||||
)::text;
|
||||
"""
|
||||
rejected = subprocess.run(
|
||||
pg14_database.command("-A", "-t", "-q", "-c", statement),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert rejected.returncode != 0
|
||||
assert "conversational_action_conflict" in rejected.stderr
|
||||
assert json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select pg_catalog.jsonb_build_object(
|
||||
'caseCount', (select pg_catalog.count(*) from public.birth_time_rectification_cases where id = '{action_id}'::uuid),
|
||||
'evidenceCount', (select pg_catalog.count(*) from public.birth_time_rectification_event_evidence where case_id = '{action_id}'::uuid),
|
||||
'billingCount', (select pg_catalog.count(*) from public.birth_time_rectification_billing where case_id = '{action_id}'::uuid),
|
||||
'receiptCount', (select pg_catalog.count(*) from public.birth_time_rectification_action_receipts where case_id = '{action_id}'::uuid),
|
||||
'profilePointer', (select rectification_case_id from public.profiles where id = '{user_id}'::uuid)
|
||||
)::text;
|
||||
"""
|
||||
)) == {
|
||||
"caseCount": 0,
|
||||
"evidenceCount": 0,
|
||||
"billingCount": 0,
|
||||
"receiptCount": 0,
|
||||
"profilePointer": None,
|
||||
}
|
||||
|
||||
|
||||
def test_import_abandon_then_paid_revision_keeps_active_time_and_charges_normally(
|
||||
pg14_database: PgDatabase,
|
||||
) -> None:
|
||||
user_id = "00000000-0000-4000-8000-000000002731"
|
||||
legacy_case_id = "00000000-0000-4000-8000-000000002732"
|
||||
import_action = "00000000-0000-4000-8000-000000002733"
|
||||
abandon_action = "00000000-0000-4000-8000-000000002734"
|
||||
paid_action = "00000000-0000-4000-8000-000000002735"
|
||||
_create_user(pg14_database, user_id, credits=10)
|
||||
_create_legacy_case(pg14_database, user_id, legacy_case_id)
|
||||
declared = {
|
||||
"birthDate": "1990-01-01",
|
||||
"reportedTime": "05:20",
|
||||
"source": "legacy_import",
|
||||
"birthTimeClue": None,
|
||||
"uncertaintyBeforeMinutes": 0,
|
||||
"uncertaintyAfterMinutes": 0,
|
||||
"birthplace": {
|
||||
"countryCode": "TW",
|
||||
"provinceCode": "TPE",
|
||||
"cityCode": "TPE-CITY",
|
||||
"districtCode": "DAAN",
|
||||
"latitude": 25.0268,
|
||||
"longitude": 121.5434,
|
||||
"timezoneOffset": 8,
|
||||
},
|
||||
}
|
||||
import_statement = f"""
|
||||
select public.import_legacy_conversational_rectification_case(
|
||||
'{user_id}'::uuid, '{import_action}'::uuid, '{legacy_case_id}'::uuid,
|
||||
0, '{import_action}'::uuid, 3, null,
|
||||
{_jsonb(declared)}, '[]'::jsonb,
|
||||
{_jsonb(_valid_turn(import_action))},
|
||||
{_jsonb({'modelId': 'synthetic-model', 'schemaValidated': True})},
|
||||
{_jsonb(_valid_private_candidate())}
|
||||
)::text;
|
||||
"""
|
||||
imported = json.loads(pg14_database.sql(import_statement))
|
||||
assert imported["billing_state"] == "migration_waived"
|
||||
assert json.loads(pg14_database.sql(import_statement)) == imported
|
||||
assert pg14_database.sql(
|
||||
f"select rectification_case_id::text from public.profiles where id = '{user_id}'::uuid"
|
||||
) == import_action
|
||||
|
||||
abandoned_turn = {
|
||||
**_valid_turn(import_action),
|
||||
"status": "abandoned",
|
||||
"turnVersion": 1,
|
||||
"actions": [],
|
||||
}
|
||||
abandoned = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select public.abandon_conversational_rectification_case(
|
||||
'{user_id}'::uuid, '{import_action}'::uuid, 0,
|
||||
'{abandon_action}'::uuid, {_jsonb(abandoned_turn)},
|
||||
{_jsonb({'modelId': 'deterministic-transition-v1', 'schemaValidated': True})},
|
||||
null
|
||||
)::text;
|
||||
"""
|
||||
))
|
||||
assert abandoned["status"] == "abandoned"
|
||||
pointed = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select pg_catalog.jsonb_build_object(
|
||||
'pointer', profile.rectification_case_id,
|
||||
'protocol', pointed.journey_protocol,
|
||||
'status', pointed.status,
|
||||
'activeTime', pg_catalog.to_char(profile.active_birth_time, 'HH24:MI')
|
||||
)::text
|
||||
from public.profiles profile
|
||||
join public.birth_time_rectification_cases pointed
|
||||
on pointed.id = profile.rectification_case_id
|
||||
where profile.id = '{user_id}'::uuid;
|
||||
"""
|
||||
))
|
||||
assert pointed == {
|
||||
"pointer": import_action,
|
||||
"protocol": "conversational-evidence-v3",
|
||||
"status": "abandoned",
|
||||
"activeTime": "04:58",
|
||||
}
|
||||
|
||||
assert _reserve(pg14_database, user_id, paid_action, price=3)["credits"] == 7
|
||||
_create_case(
|
||||
pg14_database,
|
||||
user_id,
|
||||
paid_action,
|
||||
_valid_declared_birth_input(),
|
||||
revision_of_case_id=import_action,
|
||||
)
|
||||
completed = _complete(pg14_database, user_id, paid_action)
|
||||
assert completed["billing_state"] == "charged"
|
||||
assert json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select pg_catalog.jsonb_build_object(
|
||||
'credits', profile.credits,
|
||||
'activeTime', pg_catalog.to_char(profile.active_birth_time, 'HH24:MI'),
|
||||
'profilePointer', profile.rectification_case_id,
|
||||
'billingState', billing.state,
|
||||
'revisionOf', paid.revision_of_case_id,
|
||||
'importedFrom', paid.imported_from_case_id,
|
||||
'baseline', pg_catalog.to_char(paid.baseline_active_time, 'HH24:MI')
|
||||
)::text
|
||||
from public.profiles profile
|
||||
join public.birth_time_rectification_cases paid on paid.id = '{paid_action}'::uuid
|
||||
join public.birth_time_rectification_billing billing on billing.case_id = paid.id
|
||||
where profile.id = '{user_id}'::uuid;
|
||||
"""
|
||||
)) == {
|
||||
"credits": 7,
|
||||
"activeTime": "04:58",
|
||||
"profilePointer": import_action,
|
||||
"billingState": "charged",
|
||||
"revisionOf": import_action,
|
||||
"importedFrom": None,
|
||||
"baseline": "04:58",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user