# frozen_string_literal: true # Parity harness: run an IDENTICAL scripted sequence of Service operations against the JSON Store and # PgStore, then diff the observable results. Any divergence is a bug. This is what proves the two # backends behave the same, so the store choice (HELPDESK_DATABASE_URL) stays a pure config switch. # Uses a fixed clock for determinism and a fresh (truncated) helpdesk_test DB for the PG side. # HELPDESK_TEST_DATABASE_URL=postgres://... ruby db/parity_check.rb require_relative "../lib/helpdesk/domain" require_relative "../lib/helpdesk/pg_store" TEST_URL = ENV["HELPDESK_TEST_DATABASE_URL"] or abort "set HELPDESK_TEST_DATABASE_URL" CLOCK = -> { Time.utc(2026, 7, 23, 12, 0, 0) } # fixed clock def fresh_pg db = Sequel.connect(TEST_URL) db.run "TRUNCATE orgs, campaigns, dids, people, phone_numbers, events, quarantine; ALTER SEQUENCE org_id_seq RESTART; ALTER SEQUENCE campaign_id_seq RESTART; ALTER SEQUENCE person_id_seq RESTART; ALTER SEQUENCE event_id_seq RESTART; ALTER SEQUENCE ledger_seq RESTART;" db.disconnect Helpdesk::Service.new(Helpdesk::PgStore.new(TEST_URL), clock: CLOCK) end def fresh_json = Helpdesk::Service.new(Helpdesk::Store.new, clock: CLOCK) # The identical script. Returns an ordered list of [label, observable-result] to diff. Runs the # formerly-in-place-mutating paths: webhooks, notes/resolution, people CRUD, AI-context CAS + pen-edit, # ledger apply, reconcile, command apply, quarantine reattach race. def run_script(svc) out = [] ts = "2026-07-23T11:00:00Z" # a full inbound call lifecycle svc.incoming(call_id: "c1", ts: ts, number: "+420602345678", presentation: "allowed", device_id: "dev1") svc.answered(call_id: "c1", ts: "2026-07-23T11:00:05Z") svc.ended(call_id: "c1", ts: "2026-07-23T11:01:00Z", duration_s: 55, disconnect_cause: "local") svc.recording_uploaded(recording_uuid: "u1", call_id: "c1", url: "/rec/u1", audio_path: "/tmp/u1.audio") svc.mark_transcribed(call_id: "c1", transcript: "ahoj svete") svc.mark_summarised(call_id: "c1", summary: "test call", action_items: [{ "text" => "do x", "kind" => "doc" }], caller_facts: ["fact a"], context_digest: "digest a") svc.set_notes(call_id: "c1", notes: "operator note", resolution: "solved") out << ["c1", svc.event_by_call_id("c1")] # quarantine-before-incoming race svc.recording_uploaded(recording_uuid: "u2", call_id: "c2", url: "/rec/u2", audio_path: "/tmp/u2.audio") svc.incoming(call_id: "c2", ts: "2026-07-23T11:05:00Z", number: "+420602345678", presentation: "allowed") out << ["c2_reattached_rec", svc.event_by_call_id("c2")[:recording_url]] # outgoing svc.outgoing(call_id: "c3", ts: "2026-07-23T11:10:00Z", number: "+420777111222", device_id: "dev1") out << ["c3_dir", svc.event_by_call_id("c3")[:direction]] # people CRUD pd = svc.create_person(name: "Alice", operator: "tmobile", numbers: ["+420602345678"], email: "a@x.cz") pid = pd[:id] svc.update_person(person_id: pid, position: "technik", context: "vip") svc.set_person_context(person_id: pid, context: "vip caller") out << ["person", svc.person_detail(pid)&.slice(:name, :position, :context, :operator, :notes_rev)] # AI-context CAS + pen-edit + release svc.set_person_ai_context(person_id: pid, ai_context: { "summary" => "S1", "other_context" => ["o1"] }, expected_rev: 0, built_from: { "model" => "gemini", "at" => "2026-07-23T11:11:00Z" }) stale = svc.set_person_ai_context(person_id: pid, ai_context: { "summary" => "STALE" }, expected_rev: 0) # wrong rev svc.edit_person_ai_context(person_id: pid, section: "summary", value: "PINNED") svc.release_ai_context_section(person_id: pid, section: "summary") p = svc.person_detail(pid) out << ["ai", { ctx: p[:ai_context], rev: p[:ai_context_rev], stale: stale[:stale] }] # reconcile (nothing should trip with the fixed near-time clock, but exercise it) svc.reconcile! out << ["people_count", svc.store.people.size] out << ["event_count", svc.store.events.size] # command apply (mute/hold live attrs + dtmf) on the active-ish call svc.incoming(call_id: "c4", ts: "2026-07-23T11:20:00Z", number: "+420777111222", presentation: "allowed") svc.answered(call_id: "c4", ts: "2026-07-23T11:20:03Z") svc.apply_command(id: "k1", call_id: "c4", verb: "mute") svc.apply_command(id: "k2", call_id: "c4", verb: "hold") svc.apply_command(id: "k3", call_id: "c4", verb: "dtmf", arg: "5") e4 = svc.event_by_call_id("c4") out << ["c4_attrs", { muted: e4[:muted], on_hold: e4[:on_hold], dtmf: e4[:dtmf_log] }] out end def normalize(o) # stable, backend-agnostic comparison: strip clock-derived nondeterminism is unnecessary (fixed clock), # but drop nils vs missing keys mismatch by deep JSON round-trip (both -> plain JSON structures). JSON.parse(JSON.generate(o)) end json = run_script(fresh_json) pg = run_script(fresh_pg) fails = 0 json.each_with_index do |(label, jv), i| plabel, pv = pg[i] jn = normalize(jv); pn = normalize(pv) if jn == pn puts " ok #{label}" else fails += 1 puts " DIVERGE #{label}" puts " json: #{jn.inspect[0, 300]}" puts " pg: #{pn.inspect[0, 300]}" end end puts "\n#{json.size - fails}/#{json.size} parity checks passed" exit(fails.zero? ? 0 : 1)