fix(rectification): allow accepted candidate reselection
This commit is contained in:
@@ -3093,3 +3093,18 @@
|
||||
- 防复发:Activity 只能来自服务端清理后的真实工具生命周期,不得从 Agent 正文、未来步骤或前端猜测生成;工具参数、出生资料、评分、权限、Provider metadata 与内部错误不得进入公开事件;Agent 正文、运行状态、完成凭证和候选卡必须保持单一内容所有权。
|
||||
- 相关记录:BUG-172、BUG-173、BUG-179、BUG-180
|
||||
- 修复版本:本次 staging 发布提交(精确 SHA 以提交与部署结果为准)
|
||||
|
||||
## BUG-182 | 候选卡允许“改选”但数据库把不同分钟误报为“该时间已采用”
|
||||
|
||||
- 状态:resolved(已验证,待 staging 发布与登录态业务验收)
|
||||
- 首次发现:2026-08-13
|
||||
- 最近更新:2026-08-13
|
||||
- 影响面:V9 生时校正候选卡的 `改选为此时间` 操作、Case/Profile/Result 三方采用状态一致性。
|
||||
- 用户现象:一个候选分钟已经采用后,点击同一候选结果中的另一分钟,界面返回“该时间已采用”;卡片明明显示“改选为此时间”,实际却无法切换。
|
||||
- 触发条件:`agentic_rectification_results.selected_time` 已有值,随后以同一个有效 `result_id` 请求另一个候选分钟。
|
||||
- 根因:`accept_agentic_rectification_candidate_for_case` 只实现了首次采用和同分钟幂等重放;只要请求分钟不同,就直接抛出 `agentic_rectification_candidate_already_selected`,没有实现前端合同所承诺的安全改选分支。
|
||||
- 修复:新增前向业务迁移,保留同分钟幂等;仅允许 `candidate_accepted`、未 confirmed、同一有效未过期结果、旧采用时间与 Profile/Case/Result 完全一致时改选。改选原子更新 Profile、Result 与 Case,并强制保持 `user_accepted` / `accepted` / `candidate_accepted`,不绕过显式确认门;终态、已确认、资料漂移、更新结果或非候选时间仍拒绝。
|
||||
- 验证:新增迁移合同回归覆盖顺序、事务、幂等、改选门、Profile 旧值校验、accepted 写入、confirmed/终态不可变及业务迁移隔离;新增 PostgreSQL 集成场景覆盖首次采用、跨分钟改选、三方状态落库与新分钟幂等重放。部署后仍需在 staging 登录态点击候选卡完成真实业务验收。
|
||||
- 防复发:候选卡 CTA 与数据库状态机必须共享同一行为合同;任何“改选”文案都必须有跨分钟成功路径测试,不能只测试按钮未禁用或同值幂等。
|
||||
- 相关记录:BUG-127、BUG-144、BUG-179、BUG-181
|
||||
- 修复版本:本次 staging 发布提交(精确 SHA 以提交与部署结果为准)
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
begin;
|
||||
|
||||
-- Candidate cards explicitly allow the user to change an already accepted
|
||||
-- minute. Keep same-time retries idempotent, but permit a different minute
|
||||
-- from the same live result only while the Case is still candidate_accepted
|
||||
-- and the profile still matches the previous user-accepted selection.
|
||||
create or replace function public.accept_agentic_rectification_candidate_for_case(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_result_id uuid,
|
||||
p_time time without time zone
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.agentic_rectification_cases%rowtype;
|
||||
v_result public.agentic_rectification_results%rowtype;
|
||||
v_profile public.profiles%rowtype;
|
||||
v_snapshot jsonb;
|
||||
v_selection_kind text;
|
||||
v_status text;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null or p_result_id is null or p_time is null
|
||||
or extract(second from p_time) is distinct from 0 then
|
||||
raise exception 'agentic_rectification_candidate_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select * into v_case
|
||||
from public.agentic_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id
|
||||
for update;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then
|
||||
raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select * into v_result
|
||||
from public.agentic_rectification_results
|
||||
where id = p_result_id
|
||||
and user_id = p_user_id
|
||||
and case_id = p_case_id
|
||||
for update;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_result.invalidated_at is not null or v_result.expires_at <= pg_catalog.now() then
|
||||
raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001';
|
||||
end if;
|
||||
if not v_result.selection_allowed then
|
||||
raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001';
|
||||
end if;
|
||||
if not exists (
|
||||
select 1
|
||||
from pg_catalog.jsonb_array_elements(v_result.candidates) candidate
|
||||
where candidate ->> 'time' = pg_catalog.to_char(p_time, 'HH24:MI')
|
||||
) then
|
||||
raise exception 'agentic_rectification_candidate_time_not_allowed' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
if v_result.selected_time is not null then
|
||||
select * into v_profile
|
||||
from public.profiles
|
||||
where id = p_user_id
|
||||
for update;
|
||||
|
||||
-- Same candidate and minute is a retry/double-click, not a new choice.
|
||||
if v_result.selected_time is not distinct from p_time
|
||||
and v_case.accepted_time is not distinct from p_time then
|
||||
if not found
|
||||
or v_profile.active_birth_time is distinct from v_result.selected_time
|
||||
or v_profile.birth_time is distinct from v_result.selected_time
|
||||
or v_profile.birth_time_status is distinct from (
|
||||
case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end
|
||||
) then
|
||||
raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001';
|
||||
end if;
|
||||
return jsonb_build_object(
|
||||
'success', true,
|
||||
'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'),
|
||||
'status', case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end,
|
||||
'result_id', v_result.id,
|
||||
'case_status', v_case.status,
|
||||
'idempotent', true
|
||||
);
|
||||
end if;
|
||||
|
||||
-- A card-driven switch is legal only for an unconfirmed user acceptance.
|
||||
-- It must never demote a confirmed result or mutate a non-selection Case.
|
||||
if v_case.status is distinct from 'candidate_accepted'
|
||||
or v_case.accepted_time is null
|
||||
or v_case.accepted_time is distinct from v_result.selected_time
|
||||
or v_case.confirmed_time is not null
|
||||
or v_result.selection_kind is distinct from 'user_accepted' then
|
||||
raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
-- The profile must still reflect the old accepted minute. Any independent
|
||||
-- profile edit invalidates the switch instead of silently overwriting it.
|
||||
if not found
|
||||
or v_profile.active_birth_time is distinct from v_result.selected_time
|
||||
or v_profile.birth_time is distinct from v_result.selected_time
|
||||
or v_profile.birth_time_status is distinct from 'accepted' then
|
||||
raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
if exists (
|
||||
select 1
|
||||
from public.agentic_rectification_results newer
|
||||
where newer.user_id = p_user_id
|
||||
and newer.case_id = p_case_id
|
||||
and newer.invalidated_at is null
|
||||
and newer.created_at > v_result.created_at
|
||||
) then
|
||||
raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
update public.profiles
|
||||
set active_birth_time = p_time,
|
||||
birth_time = p_time,
|
||||
birth_time_status = 'accepted',
|
||||
rectification_confidence = case
|
||||
when v_result.overall_confidence = 'high' then 100
|
||||
when v_result.overall_confidence = 'medium' then 70
|
||||
else 40
|
||||
end,
|
||||
updated_at = pg_catalog.now()
|
||||
where id = p_user_id;
|
||||
|
||||
update public.agentic_rectification_results
|
||||
set selected_time = p_time,
|
||||
selection_kind = 'user_accepted',
|
||||
selected_at = pg_catalog.now(),
|
||||
updated_at = pg_catalog.now()
|
||||
where id = v_result.id;
|
||||
|
||||
-- The profile trigger temporarily marks resumable cases needs_rebaseline;
|
||||
-- this atomic candidate switch is the intended accepted state and wins.
|
||||
update public.agentic_rectification_cases
|
||||
set status = 'candidate_accepted',
|
||||
accepted_time = p_time,
|
||||
last_activity_at = pg_catalog.now(),
|
||||
updated_at = pg_catalog.now()
|
||||
where id = v_case.id;
|
||||
|
||||
return jsonb_build_object(
|
||||
'success', true,
|
||||
'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'),
|
||||
'status', 'accepted',
|
||||
'result_id', v_result.id,
|
||||
'case_status', 'candidate_accepted',
|
||||
'idempotent', false
|
||||
);
|
||||
end if;
|
||||
|
||||
-- Fresh acceptance still requires the profile to match the Case baseline.
|
||||
v_snapshot := v_case.baseline_birth_snapshot;
|
||||
select * into v_profile
|
||||
from public.profiles
|
||||
where id = p_user_id
|
||||
for update;
|
||||
|
||||
if not found
|
||||
or v_profile.birth_date is distinct from (v_snapshot ->> 'birth_date')::date
|
||||
or v_profile.reported_birth_time is distinct from (v_snapshot ->> 'reported_birth_time')::time without time zone
|
||||
or v_profile.active_birth_time is distinct from (v_snapshot ->> 'active_birth_time')::time without time zone
|
||||
or v_profile.birth_time_source is distinct from v_snapshot ->> 'birth_time_source'
|
||||
or v_profile.birth_time_period is distinct from v_snapshot ->> 'birth_time_period'
|
||||
or v_profile.uncertainty_before_minutes is distinct from (v_snapshot ->> 'uncertainty_before_minutes')::integer
|
||||
or v_profile.uncertainty_after_minutes is distinct from (v_snapshot ->> 'uncertainty_after_minutes')::integer
|
||||
or v_profile.latitude is distinct from (v_snapshot ->> 'latitude')::double precision
|
||||
or v_profile.longitude is distinct from (v_snapshot ->> 'longitude')::double precision
|
||||
or v_profile.timezone_offset is distinct from (v_snapshot ->> 'timezone_offset')::double precision then
|
||||
raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
if exists (
|
||||
select 1
|
||||
from public.agentic_rectification_results newer
|
||||
where newer.user_id = p_user_id
|
||||
and newer.case_id = p_case_id
|
||||
and newer.invalidated_at is null
|
||||
and newer.created_at > v_result.created_at
|
||||
) then
|
||||
raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
v_selection_kind := case
|
||||
when v_result.confirmation_allowed
|
||||
and v_result.representative_time is not distinct from p_time
|
||||
then 'engine_confirmed'
|
||||
else 'user_accepted'
|
||||
end;
|
||||
v_status := case when v_selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end;
|
||||
|
||||
update public.profiles
|
||||
set active_birth_time = p_time,
|
||||
birth_time = p_time,
|
||||
birth_time_status = v_status,
|
||||
rectification_confidence = case
|
||||
when v_result.overall_confidence = 'high' then 100
|
||||
when v_result.overall_confidence = 'medium' then 70
|
||||
else 40
|
||||
end,
|
||||
updated_at = pg_catalog.now()
|
||||
where id = p_user_id;
|
||||
|
||||
update public.agentic_rectification_results
|
||||
set selected_time = p_time,
|
||||
selection_kind = v_selection_kind,
|
||||
selected_at = pg_catalog.now(),
|
||||
updated_at = pg_catalog.now()
|
||||
where id = v_result.id;
|
||||
|
||||
update public.agentic_rectification_results
|
||||
set invalidated_at = pg_catalog.now(),
|
||||
updated_at = pg_catalog.now()
|
||||
where user_id = p_user_id
|
||||
and case_id = p_case_id
|
||||
and id <> v_result.id
|
||||
and invalidated_at is null
|
||||
and selected_time is null;
|
||||
|
||||
update public.agentic_rectification_cases
|
||||
set status = 'candidate_accepted',
|
||||
accepted_time = p_time,
|
||||
last_activity_at = pg_catalog.now(),
|
||||
updated_at = pg_catalog.now()
|
||||
where id = v_case.id;
|
||||
|
||||
return jsonb_build_object(
|
||||
'success', true,
|
||||
'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'),
|
||||
'status', v_status,
|
||||
'result_id', v_result.id,
|
||||
'case_status', 'candidate_accepted',
|
||||
'idempotent', false
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.accept_agentic_rectification_candidate_for_case(uuid, uuid, uuid, time without time zone)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.accept_agentic_rectification_candidate_for_case(uuid, uuid, uuid, time without time zone)
|
||||
to service_role;
|
||||
|
||||
commit;
|
||||
@@ -990,3 +990,156 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("v9 allows changing an accepted minute within the same live candidate result", { skip: skipWithoutDocker }, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
|
||||
const userId = "77777777-7777-4777-8777-777777777777";
|
||||
const sessionId = "88888888-8888-4888-8888-888888888888";
|
||||
const caseId = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
|
||||
insert into identity.users (id, name, email, email_verified, email_verified_at)
|
||||
values ('${userId}', 'Candidate Reselection Fixture', 'candidate-reselection@example.com', true, now())
|
||||
`);
|
||||
fixture.psql(`
|
||||
update public.profiles
|
||||
set birth_date = '1997-08-08',
|
||||
reported_birth_time = '05:00',
|
||||
active_birth_time = null,
|
||||
birth_time = '05:00',
|
||||
birth_time_status = 'reported',
|
||||
birth_time_source = 'family_exact',
|
||||
birth_time_period = null,
|
||||
uncertainty_before_minutes = 10,
|
||||
uncertainty_after_minutes = 10,
|
||||
birth_place_label = '河北省邯郸市武安市',
|
||||
latitude = 36.420487,
|
||||
longitude = 114.209936,
|
||||
timezone_id = 'Asia/Shanghai',
|
||||
timezone_offset = 8
|
||||
where id = '${userId}';
|
||||
|
||||
insert into public.chat_sessions (id, user_id, title, theme, session_type, messages)
|
||||
values ('${sessionId}', '${userId}', '候选改选', 'general', 'birth_time_rectification', '[]'::jsonb);
|
||||
|
||||
insert into public.agentic_rectification_cases (
|
||||
id, user_id, session_id, status, skill_name, skill_version,
|
||||
baseline_profile_fingerprint, baseline_birth_snapshot, candidate_range
|
||||
) values (
|
||||
'${caseId}', '${userId}', '${sessionId}', 'candidate_ready',
|
||||
'${skillName}', '${skillVersion}', '${fingerprint}',
|
||||
'${JSON.stringify(snapshot)}'::jsonb,
|
||||
'${JSON.stringify(range)}'::jsonb
|
||||
);
|
||||
`);
|
||||
|
||||
const service = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
|
||||
null,
|
||||
"service_role",
|
||||
);
|
||||
const persisted = await service.rpc("persist_agentic_rectification_candidate", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_engine_result_id: "candidate-reselection-engine",
|
||||
p_algorithm_version: "candidate-reselection-v1",
|
||||
p_evidence_ledger_fingerprint: "b".repeat(64),
|
||||
p_candidate_range_fingerprint: "c".repeat(64),
|
||||
p_skill_version: skillVersion,
|
||||
p_candidate_range: range,
|
||||
p_candidates: [
|
||||
{ rank: 1, time: "05:07", relative_support: 34, tied_minute_count: 1 },
|
||||
{ rank: 2, time: "05:08", relative_support: 33, tied_minute_count: 2 },
|
||||
{ rank: 3, time: "05:09", relative_support: 33, tied_minute_count: 2 },
|
||||
],
|
||||
p_overall_confidence: "low",
|
||||
p_margin_percent: 1,
|
||||
p_selection_allowed: true,
|
||||
p_confirmation_allowed: false,
|
||||
p_representative_time: null,
|
||||
});
|
||||
assert.equal(persisted.error, null, rpcError(persisted.error));
|
||||
const resultId = String((persisted.data as Record<string, unknown>).result_id);
|
||||
|
||||
const firstAccept = await service.rpc("accept_agentic_rectification_candidate_for_case", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_time: "05:07",
|
||||
});
|
||||
assert.equal(firstAccept.error, null, rpcError(firstAccept.error));
|
||||
|
||||
const switched = await service.rpc("accept_agentic_rectification_candidate_for_case", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_time: "05:08",
|
||||
});
|
||||
assert.equal(switched.error, null, rpcError(switched.error));
|
||||
assert.deepEqual(
|
||||
{
|
||||
saved_time: (switched.data as Record<string, unknown>).saved_time,
|
||||
status: (switched.data as Record<string, unknown>).status,
|
||||
case_status: (switched.data as Record<string, unknown>).case_status,
|
||||
idempotent: (switched.data as Record<string, unknown>).idempotent,
|
||||
},
|
||||
{ saved_time: "05:08", status: "accepted", case_status: "candidate_accepted", idempotent: false },
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select active_birth_time || ':' || birth_time || ':' || birth_time_status from public.profiles where id = '${userId}'`),
|
||||
"05:08:00:05:08:00:accepted",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select selected_time || ':' || selection_kind from public.agentic_rectification_results where id = '${resultId}'`),
|
||||
"05:08:00:user_accepted",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select accepted_time || ':' || status from public.agentic_rectification_cases where id = '${caseId}'`),
|
||||
"05:08:00:candidate_accepted",
|
||||
);
|
||||
|
||||
const replay = await service.rpc("accept_agentic_rectification_candidate_for_case", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_time: "05:08",
|
||||
});
|
||||
assert.equal(replay.error, null, rpcError(replay.error));
|
||||
assert.equal((replay.data as Record<string, unknown>).idempotent, true);
|
||||
|
||||
fixture.psql(`
|
||||
update public.profiles
|
||||
set birth_time_status = 'confirmed'
|
||||
where id = '${userId}';
|
||||
update public.agentic_rectification_results
|
||||
set selection_kind = 'engine_confirmed'
|
||||
where id = '${resultId}';
|
||||
update public.agentic_rectification_cases
|
||||
set status = 'confirmed', confirmed_time = '05:08', completed_at = now()
|
||||
where id = '${caseId}';
|
||||
`);
|
||||
const confirmedSwitch = await service.rpc("accept_agentic_rectification_candidate_for_case", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_time: "05:09",
|
||||
});
|
||||
assert.match(rpcError(confirmedSwitch.error), /agentic_rectification_case_terminal/);
|
||||
} finally {
|
||||
try {
|
||||
await closeLocalPostgresDataPool(
|
||||
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
|
||||
);
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -506,3 +506,97 @@ test("parallel rectification migration stays out of the identity migration tree"
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 20260813050000_allow_rectification_candidate_reselection.sql
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const candidateReselectionMigration = readFileSync(
|
||||
new URL(
|
||||
"../supabase/migrations/20260813050000_allow_rectification_candidate_reselection.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const candidateReselectionMigrationCopy = fileURLToPath(
|
||||
new URL(
|
||||
"../db/migrations/20260813050000_allow_rectification_candidate_reselection.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
test("candidate reselection migration follows parallel cases and stays transactional", () => {
|
||||
assert.ok(
|
||||
"20260813050000_allow_rectification_candidate_reselection.sql" >
|
||||
"20260813040000_allow_parallel_rectification_cases.sql",
|
||||
);
|
||||
assert.match(candidateReselectionMigration, /^begin;[\s\S]*^commit;$/m);
|
||||
assert.match(
|
||||
candidateReselectionMigration,
|
||||
/create or replace function public\.accept_agentic_rectification_candidate_for_case\(/,
|
||||
);
|
||||
});
|
||||
|
||||
test("candidate reselection preserves replay and safely changes only accepted candidates", () => {
|
||||
assert.match(
|
||||
candidateReselectionMigration,
|
||||
/v_result\.selected_time is not distinct from p_time[\s\S]*v_case\.accepted_time is not distinct from p_time[\s\S]*'idempotent', true/,
|
||||
);
|
||||
assert.match(
|
||||
candidateReselectionMigration,
|
||||
/v_case\.status is distinct from 'candidate_accepted'/,
|
||||
);
|
||||
assert.match(
|
||||
candidateReselectionMigration,
|
||||
/v_result\.selection_kind is distinct from 'user_accepted'/,
|
||||
);
|
||||
assert.match(
|
||||
candidateReselectionMigration,
|
||||
/v_profile\.active_birth_time is distinct from v_result\.selected_time/,
|
||||
);
|
||||
assert.match(
|
||||
candidateReselectionMigration,
|
||||
/v_profile\.birth_time_status is distinct from 'accepted'/,
|
||||
);
|
||||
assert.match(
|
||||
candidateReselectionMigration,
|
||||
/set selected_time = p_time,[\s\S]*selection_kind = 'user_accepted'/,
|
||||
);
|
||||
assert.match(
|
||||
candidateReselectionMigration,
|
||||
/set active_birth_time = p_time,[\s\S]*birth_time = p_time,[\s\S]*birth_time_status = 'accepted'/,
|
||||
);
|
||||
assert.match(
|
||||
candidateReselectionMigration,
|
||||
/set status = 'candidate_accepted',[\s\S]*accepted_time = p_time/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
candidateReselectionMigration,
|
||||
/selected_time is distinct from p_time[\s\S]{0,160}agentic_rectification_candidate_already_selected/,
|
||||
);
|
||||
});
|
||||
|
||||
test("candidate reselection keeps terminal and confirmed selections immutable", () => {
|
||||
assert.match(
|
||||
candidateReselectionMigration,
|
||||
/v_case\.status in \('confirmed', 'closed', 'abandoned', 'superseded'\)[\s\S]*agentic_rectification_case_terminal/,
|
||||
);
|
||||
assert.match(
|
||||
candidateReselectionMigration,
|
||||
/v_result\.selection_kind is distinct from 'user_accepted'[\s\S]*agentic_rectification_candidate_selection_blocked/,
|
||||
);
|
||||
assert.match(candidateReselectionMigration, /birth_time_status = 'accepted'/);
|
||||
assert.doesNotMatch(
|
||||
candidateReselectionMigration,
|
||||
/set selected_time = p_time,[\s\S]{0,180}selection_kind = 'engine_confirmed'/,
|
||||
);
|
||||
});
|
||||
|
||||
test("candidate reselection migration stays out of the identity migration tree", () => {
|
||||
assert.equal(
|
||||
existsSync(candidateReselectionMigrationCopy),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user