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
415 lines
26 KiB
Bash
Executable File
415 lines
26 KiB
Bash
Executable File
#!/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 – 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"
|