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"
|
||||
@@ -16,11 +16,25 @@
|
||||
# 6. happy path: thirdparty_update + contact through promote-apply
|
||||
# --target sandbox (stubbed); a second apply is a proven no-op
|
||||
# (changed=0 for the fiche, deduped=true for the contact).
|
||||
# idempotency keys (erp#44):
|
||||
# 7. invoice-create supplier dedupe by (socid, ref_supplier) → no POST,
|
||||
# deduped:true; same key + different total ABORTS as a conflict.
|
||||
# 8. invoice-create customer dedupe by (socid, date, total, line fingerprint)
|
||||
# → no POST; a different desc misses and creates.
|
||||
# 9. payment-record dedupe by (invoice, amount, normalized tx) → no POST;
|
||||
# same tx + different amount ABORTS; a historical LONG-form stored num
|
||||
# still dedupes (composes with the erp#37 normalization).
|
||||
# 10. thirdparty-create dedupe by exact name → no POST; ambiguous ABORTS;
|
||||
# an existing fiche missing the requested role ABORTS; a miss creates.
|
||||
# 11. a deduped DRAFT with validate:true is validated on replay (converges an
|
||||
# op that died between create and validate).
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PR="${SCRIPT_DIR}/../scripts/payment-record.sh"
|
||||
TU="${SCRIPT_DIR}/../scripts/thirdparty-update.sh"
|
||||
CC="${SCRIPT_DIR}/../scripts/contact-create.sh"
|
||||
IC="${SCRIPT_DIR}/../scripts/invoice-create.sh"
|
||||
TC="${SCRIPT_DIR}/../scripts/thirdparty-create.sh"
|
||||
PA="${SCRIPT_DIR}/../scripts/promote-apply.sh"
|
||||
PP="${SCRIPT_DIR}/../scripts/promote-plan.sh"
|
||||
STUB="${SCRIPT_DIR}/stub-dol-write.sh"
|
||||
@@ -30,6 +44,8 @@ fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
bash -n "${PR}" || fail "bash -n payment-record.sh"
|
||||
bash -n "${TU}" || fail "bash -n thirdparty-update.sh"
|
||||
bash -n "${CC}" || fail "bash -n contact-create.sh"
|
||||
bash -n "${IC}" || fail "bash -n invoice-create.sh"
|
||||
bash -n "${TC}" || fail "bash -n thirdparty-create.sh"
|
||||
bash -n "${PA}" || fail "bash -n promote-apply.sh"
|
||||
bash -n "${PP}" || fail "bash -n promote-plan.sh"
|
||||
bash -n "${STUB}" || fail "bash -n stub-dol-write.sh"
|
||||
@@ -139,4 +155,175 @@ grep -q -- '-> id=88' <<<"${OUT2}" || fail "run 2: dedupe must return the
|
||||
[[ ! -f "${S6}/contact_post_body.json" ]] || fail "run 2: must NOT POST a duplicate contact"
|
||||
echo "OK: promote-apply happy path — run 1 applies (changed=2, contact id 88), run 2 is a no-op (changed=0, deduped)"
|
||||
|
||||
# ============================ erp#44 idempotency ==============================
|
||||
EPOCH_0630="$(python3 -c "import datetime; print(int(datetime.datetime(2026,6,30).timestamp()))")"
|
||||
EPOCH_0531="$(python3 -c "import datetime; print(int(datetime.datetime(2026,5,31).timestamp()))")"
|
||||
|
||||
# --- Case 7: supplier invoice dedupe by (socid, ref_supplier) ---
|
||||
S7="$(mktemp -d -t ictest7.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}" "${S7}"' EXIT
|
||||
cat > "${S7}/supplierinvoices.json" <<JSON
|
||||
[{"id":"5","ref":"FAF2026005","ref_supplier":"F1045","socid":"7","type":"0",
|
||||
"date":${EPOCH_0630},"total_ht":"214.70000000","total_ttc":"257.64000000","statut":"1",
|
||||
"lines":[{"desc":"Apport d'affaire – juin 2026","qty":"1","subprice":"214.70000000","tva_tx":"20.0000"}]}]
|
||||
JSON
|
||||
printf '%s' '{"id":"5","ref":"FAF2026005","ref_supplier":"F1045","total_ht":"214.70000000","total_ttc":"257.64000000","statut":"1"}' \
|
||||
> "${S7}/invoice_detail_5.json"
|
||||
IN7='{"socid":7,"kind":"supplier","date":"2026-06-30","ref_supplier":"F1045","validate":true,
|
||||
"lines":[{"desc":"Apport d'"'"'affaire – juin 2026","qty":1,"price_ht":214.70,"tva":20,"type":"service"}]}'
|
||||
OUT="$(printf '%s' "${IN7}" | DOL_WRITE="${STUB}" STUB_STATE="${S7}" bash "${IC}" 2>/dev/null)" \
|
||||
|| fail "si-dedupe: expected success, got $?"
|
||||
python3 -c "
|
||||
import json
|
||||
o = json.loads('''${OUT}''')
|
||||
assert o['id'] == '5' and o['deduped'] is True, o
|
||||
" || fail "si-dedupe: must return existing id 5 with deduped:true, got: ${OUT}"
|
||||
[[ ! -f "${S7}/post_body.json" ]] || fail "si-dedupe: must NOT POST when ref_supplier matches"
|
||||
[[ ! -f "${S7}/validated_endpoint" ]] || fail "si-dedupe: an already-validated match must NOT be re-validated"
|
||||
# 7b — same (socid, ref_supplier) but different total = conflict, never a dedupe
|
||||
rc=0
|
||||
printf '%s' '{"socid":7,"kind":"supplier","date":"2026-06-30","ref_supplier":"F1045",
|
||||
"lines":[{"desc":"X","qty":1,"price_ht":999,"tva":20,"type":"service"}]}' \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S7}" bash "${IC}" >/dev/null 2>"${S7}/stderr7b" || rc=$?
|
||||
[[ "${rc}" -ne 0 ]] || fail "si-conflict: same ref_supplier + different total must abort"
|
||||
grep -q 'conflict' "${S7}/stderr7b" || fail "si-conflict: error must say it is a conflict"
|
||||
[[ ! -f "${S7}/post_body.json" ]] || fail "si-conflict: must NOT POST on a conflict"
|
||||
echo "OK: supplier invoice dedupe — (socid, ref_supplier) hit returns id, no POST; total mismatch aborts"
|
||||
|
||||
# --- Case 8: customer invoice dedupe by (socid, date, total, line fingerprint) ---
|
||||
S8="$(mktemp -d -t ictest8.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}" "${S7}" "${S8}"' EXIT
|
||||
cat > "${S8}/invoices.json" <<JSON
|
||||
[{"id":"21","ref":"FAC003","socid":"1","type":"0","date":${EPOCH_0531},
|
||||
"total_ht":"1020.00000000","total_ttc":"1020.00000000","statut":"1",
|
||||
"lines":[{"desc":"Prestation mai","qty":"1","subprice":"1020.00000000","tva_tx":"0.0000"}]}]
|
||||
JSON
|
||||
printf '%s' '{"id":"21","ref":"FAC003","ref_supplier":null,"total_ht":"1020.00000000","total_ttc":"1020.00000000","statut":"1"}' \
|
||||
> "${S8}/invoice_detail_21.json"
|
||||
OUT="$(printf '%s' '{"socid":1,"kind":"customer","date":"2026-05-31",
|
||||
"lines":[{"desc":"Prestation mai","qty":1,"price_ht":1020,"tva":0,"type":"service"}]}' \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S8}" bash "${IC}" 2>/dev/null)" \
|
||||
|| fail "ci-dedupe: expected success, got $?"
|
||||
python3 -c "
|
||||
import json
|
||||
o = json.loads('''${OUT}''')
|
||||
assert o['id'] == '21' and o['deduped'] is True, o
|
||||
" || fail "ci-dedupe: must return existing id 21 with deduped:true, got: ${OUT}"
|
||||
[[ ! -f "${S8}/post_body.json" ]] || fail "ci-dedupe: must NOT POST when the fingerprint matches"
|
||||
# 8b — a different desc breaks the fingerprint: the invoice is CREATED
|
||||
printf '%s' '{"id":"77","ref":"FAC004","ref_supplier":null,"total_ht":"1020.00000000","total_ttc":"1020.00000000","statut":"0"}' \
|
||||
> "${S8}/invoice_detail_77.json"
|
||||
OUT="$(printf '%s' '{"socid":1,"kind":"customer","date":"2026-05-31",
|
||||
"lines":[{"desc":"Prestation juin","qty":1,"price_ht":1020,"tva":0,"type":"service"}]}' \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S8}" bash "${IC}" 2>/dev/null)" \
|
||||
|| fail "ci-miss: expected success, got $?"
|
||||
python3 -c "
|
||||
import json
|
||||
o = json.loads('''${OUT}''')
|
||||
assert o['id'] == '77' and o['deduped'] is False, o
|
||||
" || fail "ci-miss: a fingerprint miss must create (deduped:false), got: ${OUT}"
|
||||
[[ -f "${S8}/post_body.json" ]] || fail "ci-miss: the create path must POST"
|
||||
echo "OK: customer invoice dedupe — fingerprint hit returns id, no POST; desc change misses and creates"
|
||||
|
||||
# --- Case 9: payment dedupe by (invoice, amount, normalized tx) ---
|
||||
S9="$(mktemp -d -t prtest9.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}" "${S7}" "${S8}" "${S9}"' EXIT
|
||||
cat > "${S9}/payments.json" <<JSON
|
||||
[{"amount":"96.00000000","type":"VIR","date":"2026-06-29 12:00:00",
|
||||
"num":"${SHORT}","ref":"REF2026009","fk_bank_line":"556"}]
|
||||
JSON
|
||||
OUT="$(printf '{"invoice_id":13,"kind":"supplier","account_id":1,"amount":96,"transaction_id":"%s"}' "${LONG}" \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S9}" bash "${PR}" 2>/dev/null)" \
|
||||
|| fail "pay-dedupe: expected success, got $?"
|
||||
python3 -c "
|
||||
import json
|
||||
o = json.loads('''${OUT}''')
|
||||
assert o == {'id': None, 'bank_transaction_id': 556, 'transaction_id': '${SHORT}', 'deduped': True}, o
|
||||
" || fail "pay-dedupe: must dedupe on the normalized tx, got: ${OUT}"
|
||||
[[ ! -f "${S9}/post_body.json" ]] || fail "pay-dedupe: must NOT POST a duplicate payment"
|
||||
# 9b — same tx, different amount = conflict
|
||||
rc=0
|
||||
printf '{"invoice_id":13,"kind":"supplier","account_id":1,"amount":97,"transaction_id":"%s"}' "${LONG}" \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S9}" bash "${PR}" >/dev/null 2>"${S9}/stderr9b" || rc=$?
|
||||
[[ "${rc}" -ne 0 ]] || fail "pay-conflict: same tx + different amount must abort"
|
||||
grep -q 'conflict' "${S9}/stderr9b" || fail "pay-conflict: error must say it is a conflict"
|
||||
[[ ! -f "${S9}/post_body.json" ]] || fail "pay-conflict: must NOT POST on a conflict"
|
||||
# 9c — a HISTORICAL long-form stored num still dedupes (erp#37 composition)
|
||||
cat > "${S9}/payments.json" <<JSON
|
||||
[{"amount":"96.00000000","type":"VIR","date":"2026-06-29 12:00:00",
|
||||
"num":"${LONG}","ref":"REF2026009","fk_bank_line":"556"}]
|
||||
JSON
|
||||
OUT="$(printf '{"invoice_id":13,"kind":"supplier","account_id":1,"amount":96,"transaction_id":"%s"}' "${LONG}" \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S9}" bash "${PR}" 2>/dev/null)" \
|
||||
|| fail "pay-dedupe-longnum: expected success, got $?"
|
||||
python3 -c "
|
||||
import json
|
||||
o = json.loads('''${OUT}''')
|
||||
assert o['deduped'] is True and o['transaction_id'] == '${SHORT}', o
|
||||
" || fail "pay-dedupe-longnum: historical long num must normalize and dedupe, got: ${OUT}"
|
||||
[[ ! -f "${S9}/post_body.json" ]] || fail "pay-dedupe-longnum: must NOT POST"
|
||||
echo "OK: payment dedupe — normalized tx hit is a no-op, amount mismatch aborts, long-form history still dedupes"
|
||||
|
||||
# --- Case 10: thirdparty dedupe by exact name ---
|
||||
S10="$(mktemp -d -t tctest.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}" "${S7}" "${S8}" "${S9}" "${S10}"' EXIT
|
||||
printf '%s' '[{"id":"7","name":"Darnis Operations","client":"0","fournisseur":"1"}]' > "${S10}/thirdparties.json"
|
||||
OUT="$(printf '%s' '{"name":"Darnis Operations","role":"supplier"}' \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S10}" bash "${TC}" 2>/dev/null)" \
|
||||
|| fail "tp-dedupe: expected success, got $?"
|
||||
python3 -c "
|
||||
import json
|
||||
o = json.loads('''${OUT}''')
|
||||
assert o == {'id': 7, 'deduped': True}, o
|
||||
" || fail "tp-dedupe: must return existing id 7 with deduped:true, got: ${OUT}"
|
||||
[[ ! -f "${S10}/tp_post_body.json" ]] || fail "tp-dedupe: must NOT POST when the name matches"
|
||||
# 10b — two matches = ambiguous, abort
|
||||
printf '%s' '[{"id":"7","name":"Darnis Operations","client":"0","fournisseur":"1"},
|
||||
{"id":"8","name":"Darnis Operations","client":"0","fournisseur":"1"}]' > "${S10}/thirdparties.json"
|
||||
rc=0
|
||||
printf '%s' '{"name":"Darnis Operations","role":"supplier"}' \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S10}" bash "${TC}" >/dev/null 2>"${S10}/stderr10b" || rc=$?
|
||||
[[ "${rc}" -ne 0 ]] || fail "tp-ambiguous: 2 name matches must abort"
|
||||
grep -qi 'ambiguous' "${S10}/stderr10b" || fail "tp-ambiguous: error must say ambiguous"
|
||||
[[ ! -f "${S10}/tp_post_body.json" ]] || fail "tp-ambiguous: must NOT POST"
|
||||
# 10c — existing fiche missing the requested role = abort (refuse-never-repair)
|
||||
printf '%s' '[{"id":"7","name":"Darnis Operations","client":"0","fournisseur":"1"}]' > "${S10}/thirdparties.json"
|
||||
rc=0
|
||||
printf '%s' '{"name":"Darnis Operations","role":"client"}' \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S10}" bash "${TC}" >/dev/null 2>"${S10}/stderr10c" || rc=$?
|
||||
[[ "${rc}" -ne 0 ]] || fail "tp-role: role mismatch must abort"
|
||||
grep -q 'client' "${S10}/stderr10c" || fail "tp-role: error must name the missing role"
|
||||
[[ ! -f "${S10}/tp_post_body.json" ]] || fail "tp-role: must NOT POST"
|
||||
# 10d — no match (stub answers the Dolibarr 404) → the create path POSTs
|
||||
rm -f "${S10}/thirdparties.json"
|
||||
OUT="$(printf '%s' '{"name":"Fresh Supplier","role":"supplier"}' \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S10}" bash "${TC}" 2>/dev/null)" \
|
||||
|| fail "tp-miss: expected success, got $?"
|
||||
python3 -c "
|
||||
import json
|
||||
o = json.loads('''${OUT}''')
|
||||
assert o == {'id': 90, 'deduped': False}, o
|
||||
" || fail "tp-miss: a miss must create (deduped:false), got: ${OUT}"
|
||||
[[ -f "${S10}/tp_post_body.json" ]] || fail "tp-miss: the create path must POST"
|
||||
echo "OK: thirdparty dedupe — exact-name hit, ambiguous abort, role-mismatch abort, miss creates"
|
||||
|
||||
# --- Case 11: a deduped DRAFT with validate:true is validated (run converges) ---
|
||||
S11="$(mktemp -d -t ictest11.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}" "${S7}" "${S8}" "${S9}" "${S10}" "${S11}"' EXIT
|
||||
cat > "${S11}/supplierinvoices.json" <<JSON
|
||||
[{"id":"5","ref":"(PROV5)","ref_supplier":"F1045","socid":"7","type":"0",
|
||||
"date":${EPOCH_0630},"total_ht":"214.70000000","total_ttc":"257.64000000","statut":"0",
|
||||
"lines":[{"desc":"Apport","qty":"1","subprice":"214.70000000","tva_tx":"20.0000"}]}]
|
||||
JSON
|
||||
printf '%s' '{"id":"5","ref":"FAF2026005","ref_supplier":"F1045","total_ht":"214.70000000","total_ttc":"257.64000000","statut":"1"}' \
|
||||
> "${S11}/invoice_detail_5.json"
|
||||
OUT="$(printf '%s' '{"socid":7,"kind":"supplier","date":"2026-06-30","ref_supplier":"F1045","validate":true,
|
||||
"lines":[{"desc":"Apport","qty":1,"price_ht":214.70,"tva":20,"type":"service"}]}' \
|
||||
| DOL_WRITE="${STUB}" STUB_STATE="${S11}" bash "${IC}" 2>/dev/null)" \
|
||||
|| fail "draft-converge: expected success, got $?"
|
||||
python3 -c "
|
||||
import json
|
||||
o = json.loads('''${OUT}''')
|
||||
assert o['id'] == '5' and o['deduped'] is True and o['statut'] == '1', o
|
||||
" || fail "draft-converge: must dedupe AND report the validated statut, got: ${OUT}"
|
||||
[[ ! -f "${S11}/post_body.json" ]] || fail "draft-converge: must NOT create a second invoice"
|
||||
grep -q '/supplierinvoices/5/validate' "${S11}/validated_endpoint" \
|
||||
|| fail "draft-converge: the matched draft must be validated"
|
||||
echo "OK: draft convergence — replay validates the half-done invoice instead of duplicating it"
|
||||
|
||||
echo "OK: all offline tests passed"
|
||||
|
||||
@@ -3,21 +3,58 @@
|
||||
# the DOL_WRITE env hook). Never talks to any host — sandbox and prod are both out
|
||||
# of reach by construction. Dispatch by endpoint:
|
||||
#
|
||||
# GET …/payments → serves $STUB_STATE/payments.json when present; else
|
||||
# (post-POST correlate) a list built from post_body.json;
|
||||
# else [] — the live sandbox answers [] (200) when empty
|
||||
# GET /thirdparties?… → name-lookup: serves thirdparties.json, else the
|
||||
# Dolibarr empty behavior (HTTP 404 + non-zero)
|
||||
# GET /thirdparties/<id> → serves $STUB_STATE/thirdparty.json (or a canned
|
||||
# "before" fiche on first read)
|
||||
# PUT /thirdparties/<id> → records put_body.json/put_endpoint, merges the body
|
||||
# into thirdparty.json (so the read-after sees it)
|
||||
# POST /thirdparties → records tp_post_body.json, echoes 90
|
||||
# GET /supplierinvoices?… → dedupe listing: serves supplierinvoices.json else []
|
||||
# GET /invoices?… → dedupe listing: serves invoices.json else []
|
||||
# GET /supplierinvoices/<id> · /invoices/<id>
|
||||
# → serves invoice_detail_<id>.json, else HTTP 404
|
||||
# GET /contacts… → serves $STUB_STATE/contacts.json; mimics Dolibarr's
|
||||
# empty-list behavior (HTTP 404 + non-zero) when absent
|
||||
# POST /contacts… → records contact_post_body.json, appends the contact
|
||||
# (id 88) to contacts.json, echoes 88
|
||||
# POST <anything else> → payment behavior: records post_body.json/post_endpoint,
|
||||
# echoes 77 (unchanged from the erp#37 tests)
|
||||
# POST …/validate → records validated_endpoint (proof validation ran)
|
||||
# POST <anything else> → payment/invoice behavior: records post_body.json/
|
||||
# post_endpoint, echoes 77 (unchanged from erp#37)
|
||||
# GET <anything else> → payments list served back from post_body.json
|
||||
set -euo pipefail
|
||||
STATE="${STUB_STATE:?stub-dol-write.sh: STUB_STATE not set}"
|
||||
METHOD="$1"; ENDPOINT="$2"; BODY="${3:-}"
|
||||
case "${METHOD} ${ENDPOINT}" in
|
||||
GET\ *"/payments"*)
|
||||
if [[ -f "${STATE}/payments.json" ]]; then
|
||||
cat "${STATE}/payments.json"
|
||||
elif [[ -f "${STATE}/post_body.json" ]]; then
|
||||
python3 - "${STATE}/post_body.json" <<'PY'
|
||||
import json, sys
|
||||
body = json.load(open(sys.argv[1]))
|
||||
print(json.dumps([{"num": body.get("num_payment", ""),
|
||||
"amount": body.get("amount", ""),
|
||||
"date": "2026-06-20 12:00:00",
|
||||
"fk_bank_line": "556"}]))
|
||||
PY
|
||||
else
|
||||
printf '[]' # live sandbox: empty payment list is [] with HTTP 200
|
||||
fi
|
||||
;;
|
||||
"GET /thirdparties?"*)
|
||||
if [[ -f "${STATE}/thirdparties.json" ]]; then
|
||||
cat "${STATE}/thirdparties.json"
|
||||
else
|
||||
# Dolibarr answers 404 (not []) when a thirdparty lookup matches nothing.
|
||||
printf '%s' '{"error":{"code":404,"message":"Not Found: No third parties found"}}'
|
||||
echo "stub-dol-write.sh: HTTP 404 on GET ${ENDPOINT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
"GET /thirdparties/"*)
|
||||
if [[ -f "${STATE}/thirdparty.json" ]]; then
|
||||
cat "${STATE}/thirdparty.json"
|
||||
@@ -40,6 +77,26 @@ json.dump(cur, open(p, "w"), ensure_ascii=False)
|
||||
print(json.dumps(cur, ensure_ascii=False))
|
||||
PY
|
||||
;;
|
||||
"POST /thirdparties")
|
||||
printf '%s' "${BODY}" > "${STATE}/tp_post_body.json"
|
||||
echo "90"
|
||||
;;
|
||||
"GET /supplierinvoices?"*)
|
||||
if [[ -f "${STATE}/supplierinvoices.json" ]]; then cat "${STATE}/supplierinvoices.json"; else printf '[]'; fi
|
||||
;;
|
||||
"GET /invoices?"*)
|
||||
if [[ -f "${STATE}/invoices.json" ]]; then cat "${STATE}/invoices.json"; else printf '[]'; fi
|
||||
;;
|
||||
"GET /supplierinvoices/"*|"GET /invoices/"*)
|
||||
ID="${ENDPOINT##*/}"
|
||||
if [[ -f "${STATE}/invoice_detail_${ID}.json" ]]; then
|
||||
cat "${STATE}/invoice_detail_${ID}.json"
|
||||
else
|
||||
printf '%s' '{"error":{"code":404,"message":"Not Found"}}'
|
||||
echo "stub-dol-write.sh: HTTP 404 on GET ${ENDPOINT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
"GET /contacts"*)
|
||||
if [[ -f "${STATE}/contacts.json" ]]; then
|
||||
cat "${STATE}/contacts.json"
|
||||
@@ -63,6 +120,10 @@ json.dump(rows, open(p, "w"), ensure_ascii=False)
|
||||
PY
|
||||
echo "88"
|
||||
;;
|
||||
POST\ *"/validate")
|
||||
printf '%s\n' "${ENDPOINT}" > "${STATE}/validated_endpoint"
|
||||
echo '{"success":1}'
|
||||
;;
|
||||
POST\ *)
|
||||
printf '%s' "${BODY}" > "${STATE}/post_body.json"
|
||||
printf '%s\n' "${ENDPOINT}" > "${STATE}/post_endpoint"
|
||||
|
||||
Reference in New Issue
Block a user