feat(write-skill): idempotency keys — manifest replay is a no-op (erp#44)
Learning #4 of the 2026-07-11 rehearsal: manifest B failed mid-run and could not be re-applied — op 1 (the DARNIS invoice) had already run and a replay would have duplicated it. Every write op now dedupes BEFORE any POST: - thirdparty-create.sh: by exact name (promote '#thirdparty:name=' semantics); ambiguous (2+) aborts; an existing fiche missing the requested role aborts (refuse-never-repair). Emits {"id", "deduped"} instead of a bare id. - invoice-create.sh: supplier kind by (socid, ref_supplier) — same key with a different total aborts as a conflict; customer kind (or supplier without ref_supplier) by (socid, date, total_ttc ±0.02, line fingerprint) with descs HTML-unescaped. Credit notes are never candidates. A deduped DRAFT with validate:true is validated on replay, so an interrupted run converges. - payment-record.sh: by (invoice, amount, normalized transaction_id), composing with the erp#37 varchar(50) normalization on BOTH sides so historical long-form nums still match; same tx + different amount aborts; without a tx id there is no dedupe key (warned). Dedupe answers id:null (the payments list exposes no paiement rowid) + the existing bank line. - All three refuse to POST blind when the dedupe lookup fails with anything but the documented empty-list 404 (the voir_tous trap would otherwise mint dupes). - promote-apply.sh: marks each op created / deduped=true inline and totals them in the summary — an all-deduped second run is visible proof of a no-op. - promote-plan.sh: advertises each op's dedupe key (and flags tx=MISSING as 'a replay WILL double-pay'). Proof: - tests/run-tests.sh: 5 new offline cases (11 total) — dedupe hits POST nothing, conflicts/ambiguity abort pre-POST, long-form history dedupes, draft convergence validates; stub extended to serve the new lookups with the live-observed empty behaviors ([] for invoices/payments, 404 for tiers). - tests/replay-idempotency.sh (new, live): double-applies a self-contained manifest on the sandbox — run 1 '3 created' (rows 1/1/1), run 2 '3 deduped' with row counts unchanged and the stored num in erp#37 short form. - The historic manifest-B now replays on the sandbox as 5/5 deduped, zero new rows — the exact replay Learning #4 declared impossible. SKILL.md updated in the same change (per-op dedupe keys, replay-safety section, gotchas); the 2026-07-11 runbook's Learning #4 carries a dated resolution addendum. Closes erp#44. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# erp#44 acceptance — LIVE double-apply on the SANDBOX: applying the same
|
||||
# manifest twice must be a no-op the second time (all ops deduped, zero new
|
||||
# rows). This is the replay that was IMPOSSIBLE after the 2026-07-11 rehearsal's
|
||||
# manifest B failed mid-run (Learning #4: re-applying would have duplicated the
|
||||
# DARNIS invoice).
|
||||
#
|
||||
# What it does (writes go ONLY through the host-guarded dol-write.sh):
|
||||
# 1. builds a small self-contained manifest with a unique-per-run fixture:
|
||||
# one supplier thirdparty + one validated supplier invoice (Qonto-style
|
||||
# transaction id, so the erp#37 normalization is exercised too) + one payment
|
||||
# 2. applies it → expects 3 created, no dedupe
|
||||
# 3. applies it AGAIN → expects 3 deduped, zero new rows (verified by
|
||||
# row-counting thirdparties / invoices / payments via the API)
|
||||
#
|
||||
# Sandbox etiquette: the fixture rows stay behind (the sandbox is disposable;
|
||||
# a checkpoint refresh reclaims them). Run from anywhere:
|
||||
# .claude/skills/dolibarr-sandbox-write/tests/replay-idempotency.sh [evidence-dir]
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SCRIPTS="${SCRIPT_DIR}/../scripts"
|
||||
W="${SCRIPTS}/dol-write.sh" # deliberately NOT $DOL_WRITE — this test is live
|
||||
unset DOL_WRITE || true
|
||||
EV="${1:-$(mktemp -d -t idem44.XXXXXX)}"
|
||||
mkdir -p "${EV}"
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
NAME="IDEM44 Replay Fixture ${STAMP}"
|
||||
REFSUP="IDEM44-${STAMP}"
|
||||
TX="arcodange-idem44-transaction-${STAMP}-e2e-replay" # Qonto-style long form
|
||||
TX_SHORT="${STAMP}-e2e-replay" # its normalized num
|
||||
cat > "${EV}/manifest.json" <<JSON
|
||||
[
|
||||
{ "op": "thirdparty", "ref": "tp",
|
||||
"input": { "name": "${NAME}", "role": "supplier" } },
|
||||
{ "op": "invoice", "ref": "inv",
|
||||
"input": { "socid": "@tp", "kind": "supplier", "date": "2026-07-01",
|
||||
"ref_supplier": "${REFSUP}", "validate": true,
|
||||
"lines": [ { "desc": "IDEM44 replay fixture — service",
|
||||
"qty": 1, "price_ht": 100.00, "tva": 20,
|
||||
"type": "service" } ] } },
|
||||
{ "op": "payment",
|
||||
"input": { "invoice_id": "@inv", "kind": "supplier", "mode": "VIR",
|
||||
"account_id": 1, "date": "2026-07-02", "amount": 120.00,
|
||||
"transaction_id": "${TX}",
|
||||
"comment": "idem44 replay-idempotency test" } }
|
||||
]
|
||||
JSON
|
||||
|
||||
count_rows() { # $1 = path, counts a JSON array (Dolibarr 404-on-empty => 0)
|
||||
local out
|
||||
if out="$("${W}" GET "$1" 2>/dev/null)"; then
|
||||
python3 -c "import json,sys; r=json.load(sys.stdin); print(len(r) if isinstance(r,list) else 0)" <<<"${out}"
|
||||
else
|
||||
echo 0
|
||||
fi
|
||||
}
|
||||
FLT="$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(\"(t.nom:=:'%s')\" % sys.argv[1]))" "${NAME}")"
|
||||
tp_count() { count_rows "/thirdparties?limit=100&sqlfilters=${FLT}"; }
|
||||
inv_count() { count_rows "/supplierinvoices?thirdparty_ids=$1&limit=500"; }
|
||||
pay_count() { count_rows "/supplierinvoices/$1/payments"; }
|
||||
|
||||
echo "== erp#44 replay-idempotency — fixture ${STAMP} (evidence: ${EV}) =="
|
||||
[[ "$(tp_count)" == "0" ]] || fail "fixture name already exists on the sandbox (clock collision?)"
|
||||
|
||||
echo; echo "-- RUN 1: expect 3 created ------------------------------------------------"
|
||||
"${SCRIPTS}/promote-apply.sh" "${EV}/manifest.json" --target sandbox \
|
||||
| tee "${EV}/run1.out"
|
||||
grep -q 'deduped' "${EV}/run1.out" && fail "run 1: nothing may dedupe on a fresh fixture"
|
||||
[[ "$(grep -c ' created' "${EV}/run1.out")" == "3" ]] || fail "run 1: expected 3 created ops"
|
||||
grep -q '(3 created)' "${EV}/run1.out" || fail "run 1: summary must say (3 created)"
|
||||
|
||||
TPID="$(python3 -c "import json,sys,re
|
||||
m=re.search(r'ref -> id: (\{.*\})', open(sys.argv[1]).read()); print(json.loads(m.group(1))['tp'])" "${EV}/run1.out")"
|
||||
INVID="$(python3 -c "import json,sys,re
|
||||
m=re.search(r'ref -> id: (\{.*\})', open(sys.argv[1]).read()); print(json.loads(m.group(1))['inv'])" "${EV}/run1.out")"
|
||||
|
||||
TP1="$(tp_count)"; INV1="$(inv_count "${TPID}")"; PAY1="$(pay_count "${INVID}")"
|
||||
echo "row counts after run 1: thirdparties=${TP1} invoices=${INV1} payments=${PAY1}" | tee "${EV}/counts-run1.txt"
|
||||
[[ "${TP1}" == "1" && "${INV1}" == "1" && "${PAY1}" == "1" ]] || fail "run 1 must have created exactly 1 of each"
|
||||
|
||||
echo; echo "-- RUN 2 (same manifest): expect 3 deduped, zero new rows -----------------"
|
||||
"${SCRIPTS}/promote-apply.sh" "${EV}/manifest.json" --target sandbox \
|
||||
| tee "${EV}/run2.out"
|
||||
[[ "$(grep -c 'deduped=true' "${EV}/run2.out")" == "3" ]] || fail "run 2: all 3 ops must dedupe"
|
||||
grep -q ' created' "${EV}/run2.out" && fail "run 2: nothing may be created on a replay"
|
||||
grep -q '(3 deduped)' "${EV}/run2.out" || fail "run 2: summary must say (3 deduped)"
|
||||
|
||||
TP2="$(tp_count)"; INV2="$(inv_count "${TPID}")"; PAY2="$(pay_count "${INVID}")"
|
||||
echo "row counts after run 2: thirdparties=${TP2} invoices=${INV2} payments=${PAY2}" | tee "${EV}/counts-run2.txt"
|
||||
[[ "${TP2}" == "${TP1}" && "${INV2}" == "${INV1}" && "${PAY2}" == "${PAY1}" ]] \
|
||||
|| fail "run 2 must add ZERO rows (run1: ${TP1}/${INV1}/${PAY1}, run2: ${TP2}/${INV2}/${PAY2})"
|
||||
|
||||
# The payment's stored num must be the erp#37 canonical short form.
|
||||
"${W}" GET "/supplierinvoices/${INVID}/payments" > "${EV}/payments.json"
|
||||
grep -q "\"${TX_SHORT}\"" "${EV}/payments.json" \
|
||||
|| fail "stored num must be the normalized short form ${TX_SHORT}"
|
||||
|
||||
echo
|
||||
echo "PASS: replay is a no-op — run 1 created 3 rows (tp=${TPID}, inv=${INVID}), run 2 deduped all 3, row counts unchanged (${TP2}/${INV2}/${PAY2})."
|
||||
echo "Evidence in ${EV}: manifest.json run1.out run2.out counts-run*.txt payments.json"
|
||||
Reference in New Issue
Block a user