Files
erp/.claude/skills/dolibarr-sandbox-write/tests/run-tests.sh
arcodangeandClaude Fable 5 fb13bdcc4f feat(write-skill): GED attach op — upload the source document onto its invoice (erp#43)
document-attach.sh uploads a source piece (the supplier's own PDF) onto an
invoice's GED via POST /documents/upload — idempotent by (object, filename,
sha256): before any POST the object's GED is listed and a same-named entry is
downloaded back and sha256-compared. Identical → deduped no-op; different
content → ABORT (refuse-never-repair, overwriteifexists always 0, never
Dolibarr's overwrite flag). Read-back after upload: re-list + download +
sha256-verify. Module-relative download paths are derived from the listing's
fullname (supplier invoices carry an id-derived get_exdir prefix like
9/2/FAF2026013/…, so reconstruction would be wrong).

Promote integration: new `attach` op in promote-plan/promote-apply (OP_SCRIPT),
object_id resolvable via @ref and #supplierinvoice lookups; a relative `file`
resolves against the manifest's directory (replay packs carry pdfs/ beside the
manifest, gitignored — README documents the books@ re-fetch message ids).
promote-plan prints each file's sha256 (or a loud MISSING) at review time.
CLI: `arcodange sandbox attach`.

Proof: offline case 12 in tests/run-tests.sh (upload body, dedupe, conflict
abort, field refusal, manifest-relative resolution via stubbed /documents);
live: manifest-C-ged-attach.json applied twice on the sandbox — run 1 four
created, run 2 four deduped, one GED file per FAF2026010-013, stored sha256s
equal to the re-fetched sources; tests/replay-idempotency.sh extended with an
attach op (4 created → 4 deduped, ged_files count unchanged) and a live
same-name/different-bytes abort verified.

Closes erp#43

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
2026-07-19 00:11:59 +02:00

415 lines
26 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Offline tests for the write skill — everything runs against
# tests/stub-dol-write.sh via the DOL_WRITE env hook, so NOTHING is written to
# the sandbox or prod (zero credentials, zero network).
#
# payment-record.sh (the erp#37 varchar(50) fix):
# 1. Long Qonto id → POST carries the UUID suffix; JSON reports it; stderr says so.
# 2. Wise numeric id → passes through untouched, no normalization notice.
# 3. Id still >50 chars after normalization → refused BEFORE any POST, error
# cites varchar(50) (never a silent truncation).
# client-dossier ops (erp#65 phase 1):
# 4. thirdparty-update.sh refuses a non-allowlisted field (code_client) BEFORE
# any PUT, naming the offender.
# 5. contact-create.sh dedupes on a case-insensitive email match → no POST,
# {"deduped": true}; and refuses the WIP payloads' `soc2` (→ `poste`).
# 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).
# GED attach (erp#43):
# 12. document-attach.sh uploads with overwriteifexists=0 + read-back sha256;
# an identical stored file dedupes (no POST); the same filename with
# DIFFERENT content ABORTS (never an overwrite); unknown fields refused;
# promote-apply resolves a manifest-relative "file" and promote-plan
# prints its sha256.
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"
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"
STATE="$(mktemp -d -t prtest.XXXXXX)"
trap 'rm -rf "${STATE}"' EXIT
LONG="arcodange-1246-1-transaction-019f14c5-e254-7ac9-9e9f-307ed9-d55f44"
SHORT="019f14c5-e254-7ac9-9e9f-307ed9-d55f44"
# --- Case 1: long Qonto id is normalized to the UUID suffix ---
OUT="$(printf '{"invoice_id":13,"kind":"supplier","account_id":1,"amount":96,"transaction_id":"%s"}' "${LONG}" \
| DOL_WRITE="${STUB}" STUB_STATE="${STATE}" bash "${PR}" 2>"${STATE}/stderr1")" \
|| fail "long-qonto-id: expected success, got $?"
grep -q "\"num_payment\": \"${SHORT}\"" "${STATE}/post_body.json" \
|| fail "long-qonto-id: POST body must carry the SHORT num, got: $(cat "${STATE}/post_body.json")"
python3 -c "
import json, sys
o = json.loads('''${OUT}''')
assert o['transaction_id'] == '${SHORT}', o
assert o['id'] == 77 and o['bank_transaction_id'] == 556, o
" || fail "long-qonto-id: output JSON must report the normalized num, got: ${OUT}"
grep -q 'normalized' "${STATE}/stderr1" || fail "long-qonto-id: normalization must be announced on stderr"
# --- Case 2: Wise numeric id passes through unchanged ---
OUT="$(printf '{"invoice_id":19,"account_id":2,"transaction_id":"2159468139"}' \
| DOL_WRITE="${STUB}" STUB_STATE="${STATE}" bash "${PR}" 2>"${STATE}/stderr2")" \
|| fail "wise-id: expected success, got $?"
grep -q '"num_payment": "2159468139"' "${STATE}/post_body.json" \
|| fail "wise-id: POST body must carry the id untouched"
grep -q 'normalized' "${STATE}/stderr2" && fail "wise-id: must NOT announce a normalization"
# --- Case 3: >50 chars after normalization is refused before any POST ---
rm -f "${STATE}/post_body.json" "${STATE}/post_endpoint"
BAD="qonto-migration-batch-7-payment-reference-0123456789-0123456789" # 63 chars, no "transaction-"
rc=0
printf '{"invoice_id":13,"kind":"supplier","account_id":1,"amount":96,"transaction_id":"%s"}' "${BAD}" \
| DOL_WRITE="${STUB}" STUB_STATE="${STATE}" bash "${PR}" >/dev/null 2>"${STATE}/stderr3" || rc=$?
[[ "${rc}" -ne 0 ]] || fail "overlong-id: must exit non-zero"
[[ ! -f "${STATE}/post_body.json" ]] || fail "overlong-id: must refuse BEFORE any POST"
grep -q 'varchar(50)' "${STATE}/stderr3" || fail "overlong-id: error must cite the varchar(50) constraint"
echo "OK: payment-record normalization tests passed (long→short, wise untouched, >50 refused pre-POST)"
# --- Case 4: thirdparty-update refuses a non-allowlisted field pre-PUT ---
S4="$(mktemp -d -t tutest.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}"' EXIT
rc=0
printf '{"name":"KissMetrics","code_client":"CL9999"}' \
| DOL_WRITE="${STUB}" STUB_STATE="${S4}" bash "${TU}" 1 - >/dev/null 2>"${S4}/stderr" || rc=$?
[[ "${rc}" -ne 0 ]] || fail "allowlist: payload with code_client must be refused (exit non-zero)"
[[ ! -f "${S4}/put_body.json" ]] || fail "allowlist: refusal must happen BEFORE any PUT"
grep -q 'code_client' "${S4}/stderr" || fail "allowlist: the error must name the offending field"
grep -qi 'allowlist' "${S4}/stderr" || fail "allowlist: the error must say it is an allowlist refusal"
echo "OK: thirdparty-update allowlist — code_client refused pre-PUT, offender named"
# --- Case 5: contact-create dedupes by case-insensitive email → no POST ---
S5="$(mktemp -d -t cctest.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}"' EXIT
printf '%s' '[{"id":"41","socid":"1","lastname":"ROOTERING","firstname":"hendrik","poste":"COO","email":"[email protected]"}]' \
> "${S5}/contacts.json"
OUT="$(printf '{"socid":"1","lastname":"Rootering","firstname":"Hendrik","poste":"COO","email":"[email protected]"}' \
| DOL_WRITE="${STUB}" STUB_STATE="${S5}" bash "${CC}" 2>/dev/null)" \
|| fail "contact-dedupe: expected success, got $?"
python3 -c "
import json
o = json.loads('''${OUT}''')
assert o == {'id': 41, 'deduped': True}, o
" || fail "contact-dedupe: must return the existing id with deduped:true, got: ${OUT}"
[[ ! -f "${S5}/contact_post_body.json" ]] || fail "contact-dedupe: must NOT POST when a match exists"
# 5b — the WIP payloads' soc2 is not a Dolibarr field: refuse, point to poste
rc=0
printf '{"socid":"1","lastname":"Rootering","soc2":"COO"}' \
| DOL_WRITE="${STUB}" STUB_STATE="${S5}" bash "${CC}" >/dev/null 2>"${S5}/stderr5b" || rc=$?
[[ "${rc}" -ne 0 ]] || fail "soc2: must be refused (exit non-zero)"
grep -q 'poste' "${S5}/stderr5b" || fail "soc2: the error must point to 'poste'"
[[ ! -f "${S5}/contact_post_body.json" ]] || fail "soc2: refusal must happen BEFORE any POST"
echo "OK: contact-create dedupe — email match returns existing id, no POST; soc2 refused → poste"
# --- Case 6: happy path — both ops through promote-apply; re-apply is a no-op ---
S6="$(mktemp -d -t patest.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}"' EXIT
cat > "${S6}/manifest.json" <<'JSON'
[
{ "op": "thirdparty_update", "ref": "tp",
"input": { "socid": 1,
"fields": { "email": "[email protected]", "note_public": "NEW NOTE — dossier v2" } } },
{ "op": "contact", "ref": "ct",
"input": { "socid": "1", "lastname": "Rootering", "firstname": "Hendrik",
"poste": "COO", "email": "[email protected]" } }
]
JSON
bash "${PP}" "${S6}/manifest.json" >/dev/null || fail "promote-plan: must render the new op kinds"
OUT1="$(DOL_WRITE="${STUB}" STUB_STATE="${S6}" bash "${PA}" "${S6}/manifest.json" --target sandbox 2>/dev/null)" \
|| fail "promote-apply run 1: expected success, got $?"
grep -q 'thirdparty_update' <<<"${OUT1}" || fail "run 1: thirdparty_update op must be reported"
grep -q 'changed=2' <<<"${OUT1}" || fail "run 1: both fields must read back as changed, got: ${OUT1}"
grep -q 'deduped' <<<"${OUT1}" && fail "run 1: nothing must dedupe on a fresh target"
grep -q -- '-> id=88' <<<"${OUT1}" || fail "run 1: contact must be created (id 88), got: ${OUT1}"
grep -q '"note_public": "NEW NOTE — dossier v2"' "${S6}/put_body.json" \
|| fail "run 1: PUT body must carry the new note, got: $(cat "${S6}/put_body.json")"
grep -q '"poste": "COO"' "${S6}/contact_post_body.json" \
|| fail "run 1: contact POST must carry poste=COO, got: $(cat "${S6}/contact_post_body.json")"
rm -f "${S6}/contact_post_body.json"
OUT2="$(DOL_WRITE="${STUB}" STUB_STATE="${S6}" bash "${PA}" "${S6}/manifest.json" --target sandbox 2>/dev/null)" \
|| fail "promote-apply run 2: expected success, got $?"
grep -q 'changed=0 (no-op)' <<<"${OUT2}" || fail "run 2: thirdparty_update must be a no-op, got: ${OUT2}"
grep -q 'deduped=true' <<<"${OUT2}" || fail "run 2: contact must dedupe, got: ${OUT2}"
grep -q -- '-> id=88' <<<"${OUT2}" || fail "run 2: dedupe must return the run-1 id"
[[ ! -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 &ndash; 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"
# --- Case 12: document-attach (erp#43) — upload, sha256 dedupe, conflict abort ---
DA="${SCRIPT_DIR}/../scripts/document-attach.sh"
bash -n "${DA}" || fail "bash -n document-attach.sh"
S12="$(mktemp -d -t datest.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}" "${S7}" "${S8}" "${S9}" "${S10}" "${S11}" "${S12}"' EXIT
printf 'ged43 offline fixture' > "${S12}/src.pdf"
SRC_SHA="$(python3 -c "import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "${S12}/src.pdf")"
SRC_B64="$(python3 -c "import base64,sys; print(base64.b64encode(open(sys.argv[1],'rb').read()).decode())" "${S12}/src.pdf")"
printf '%s' '{"id":"29","ref":"FAF2026013","ref_supplier":"F1045","statut":"1"}' \
> "${S12}/invoice_detail_29.json"
# 12a — fresh attach: 404 listing → upload (overwriteifexists=0) → read-back sha
OUT="$(printf '{"modulepart":"facture_fournisseur","object_id":29,"file":"%s"}' "${S12}/src.pdf" \
| DOL_WRITE="${STUB}" STUB_STATE="${S12}" bash "${DA}" 2>/dev/null)" \
|| fail "attach-fresh: expected success, got $?"
python3 -c "
import json
o = json.loads('''${OUT}''')
assert o['deduped'] is False and o['sha256'] == '${SRC_SHA}' and o['ref'] == 'FAF2026013' \
and o['filename'] == 'src.pdf' and o['object_id'] == 29, o
" || fail "attach-fresh: bad output: ${OUT}"
python3 -c "
import json
b = json.load(open('${S12}/upload_body.json'))
assert b['overwriteifexists'] == '0' and b['fileencoding'] == 'base64' \
and b['ref'] == 'FAF2026013' and b['filecontent'] == '${SRC_B64}', b
" || fail "attach-fresh: upload body must carry base64 content + overwriteifexists=0"
# 12b — re-attach identical content (staged listing + download, NO upload state) → dedupe
S12B="$(mktemp -d -t datest12b.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}" "${S7}" "${S8}" "${S9}" "${S10}" "${S11}" "${S12}" "${S12B}"' EXIT
cp "${S12}/src.pdf" "${S12B}/src.pdf"; cp "${S12}/invoice_detail_29.json" "${S12B}/"
printf '%s' '[{"name":null,"relativename":"src.pdf","type":"file","level1name":"FAF2026013",
"fullname":"/var/www/documents/fournisseur/facture/0/3/FAF2026013/src.pdf","size":21}]' \
> "${S12B}/documents.json"
printf '{"filename":"src.pdf","content-type":"application/pdf","filesize":21,"content":"%s"}' "${SRC_B64}" \
> "${S12B}/document_download.json"
OUT="$(printf '{"modulepart":"facture_fournisseur","object_id":29,"file":"%s"}' "${S12B}/src.pdf" \
| DOL_WRITE="${STUB}" STUB_STATE="${S12B}" bash "${DA}" 2>/dev/null)" \
|| fail "attach-dedupe: expected success, got $?"
python3 -c "
import json
o = json.loads('''${OUT}''')
assert o['deduped'] is True and o['sha256'] == '${SRC_SHA}', o
" || fail "attach-dedupe: identical content must dedupe, got: ${OUT}"
[[ ! -f "${S12B}/upload_body.json" ]] || fail "attach-dedupe: must NOT upload on a sha256 match"
# 12c — same filename, DIFFERENT content → abort, no upload
OTHER_B64="$(printf 'ged43 DIFFERENT bytes' | python3 -c "import base64,sys; print(base64.b64encode(sys.stdin.buffer.read()).decode())")"
printf '{"filename":"src.pdf","content-type":"application/pdf","filesize":21,"content":"%s"}' "${OTHER_B64}" \
> "${S12B}/document_download.json"
rc=0
printf '{"modulepart":"facture_fournisseur","object_id":29,"file":"%s"}' "${S12B}/src.pdf" \
| DOL_WRITE="${STUB}" STUB_STATE="${S12B}" bash "${DA}" >/dev/null 2>"${S12B}/stderr12c" || rc=$?
[[ "${rc}" -ne 0 ]] || fail "attach-conflict: same name + different content must abort"
grep -q 'DIFFERENT content' "${S12B}/stderr12c" || fail "attach-conflict: error must say DIFFERENT content"
[[ ! -f "${S12B}/upload_body.json" ]] || fail "attach-conflict: must NOT upload on a conflict"
# 12d — unknown field refused before any request
rc=0
printf '{"modulepart":"facture_fournisseur","object_id":29,"file":"%s","overwrite":true}' "${S12}/src.pdf" \
| DOL_WRITE="${STUB}" STUB_STATE="${S12}" bash "${DA}" >/dev/null 2>"${S12}/stderr12d" || rc=$?
[[ "${rc}" -ne 0 ]] || fail "attach-unknown-field: must refuse unknown fields"
grep -q 'overwrite' "${S12}/stderr12d" || fail "attach-unknown-field: error must name the offender"
# 12e — promote-apply resolves a manifest-relative file; promote-plan prints its sha
S12E="$(mktemp -d -t datest12e.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}" "${S7}" "${S8}" "${S9}" "${S10}" "${S11}" "${S12}" "${S12B}" "${S12E}"' EXIT
mkdir -p "${S12E}/pack/pdfs"
cp "${S12}/src.pdf" "${S12E}/pack/pdfs/src.pdf"
printf '%s' '{"id":"29","ref":"FAF2026013","ref_supplier":"F1045","statut":"1"}' \
> "${S12E}/invoice_detail_29.json"
cat > "${S12E}/pack/manifest.json" <<'JSON'
[ { "op": "attach",
"input": { "modulepart": "facture_fournisseur", "object_id": 29,
"file": "pdfs/src.pdf" } } ]
JSON
( cd "${S12E}" \
&& bash "${PP}" pack/manifest.json > plan.out 2>&1 \
&& DOL_WRITE="${STUB}" STUB_STATE="${S12E}" bash "${PA}" pack/manifest.json --target sandbox > apply.out 2>&1 ) \
|| fail "attach-promote: plan/apply failed: $(cat "${S12E}/plan.out" "${S12E}/apply.out" 2>/dev/null)"
grep -q "sha256=${SRC_SHA}" "${S12E}/plan.out" || fail "attach-promote: plan must print the file sha256"
grep -q 'attach' "${S12E}/apply.out" || fail "attach-promote: apply must run the attach op"
grep -q '(1 created)' "${S12E}/apply.out" || fail "attach-promote: summary must say (1 created)"
[[ -f "${S12E}/upload_body.json" ]] || fail "attach-promote: the manifest-relative file must reach the upload"
echo "OK: document-attach — upload + read-back, sha256 dedupe, conflict abort, field refusal, manifest-relative file"
echo "OK: all offline tests passed"