feat(write-skill): client-dossier ops — thirdparty update (allowlisted) + idempotent contacts
Part of erp#65 (phase 1). Ledger grammar "thirdparty complete" gets its op: allowlisted non-ledger fields, per-field diff read-back. Contacts are born idempotent (dedupe by email then name). Promote ops wired both targets, offline stub tests, SKILL.md workflows, KM dossier manifest (unsigned-contract truth fix + EIN-to-collect note). Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -1,20 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Offline tests for payment-record.sh's transaction_id normalization — the writer
|
||||
# side of the varchar(50) fix (Dolibarr's num_payment columns truncate at 50 chars,
|
||||
# Qonto ids run ~67). Uses tests/stub-dol-write.sh via the DOL_WRITE env hook, so
|
||||
# NOTHING is written to the sandbox or prod.
|
||||
# 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).
|
||||
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"
|
||||
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 "${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)"
|
||||
@@ -56,3 +73,70 @@ printf '{"invoice_id":13,"kind":"supplier","account_id":1,"amount":96,"transacti
|
||||
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)"
|
||||
|
||||
echo "OK: all offline tests passed"
|
||||
|
||||
@@ -1,18 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Offline stand-in for dol-write.sh, used ONLY by tests/run-tests.sh (injected via
|
||||
# the DOL_WRITE env hook). Records the POST body under $STUB_STATE, then serves it
|
||||
# back as the payments list on GET so payment-record.sh can correlate. Never talks
|
||||
# to any host — sandbox and prod are both out of reach by construction.
|
||||
# the DOL_WRITE env hook). Never talks to any host — sandbox and prod are both out
|
||||
# of reach by construction. Dispatch by endpoint:
|
||||
#
|
||||
# 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)
|
||||
# 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)
|
||||
# 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}" in
|
||||
POST)
|
||||
printf '%s' "${BODY}" > "${STATE}/post_body.json"
|
||||
case "${METHOD} ${ENDPOINT}" in
|
||||
"GET /thirdparties/"*)
|
||||
if [[ -f "${STATE}/thirdparty.json" ]]; then
|
||||
cat "${STATE}/thirdparty.json"
|
||||
else
|
||||
printf '%s' '{"id":"1","name":"KissMetrics","name_alias":null,"email":"","note_public":"OLD NOTE (pre-dossier)","zip":null,"town":null}'
|
||||
fi
|
||||
;;
|
||||
"PUT /thirdparties/"*)
|
||||
printf '%s' "${BODY}" > "${STATE}/put_body.json"
|
||||
printf '%s\n' "${ENDPOINT}" > "${STATE}/put_endpoint"
|
||||
python3 - "${STATE}" "${BODY}" <<'PY'
|
||||
import json, os, sys
|
||||
state, body = sys.argv[1], json.loads(sys.argv[2])
|
||||
p = os.path.join(state, "thirdparty.json")
|
||||
cur = (json.load(open(p)) if os.path.exists(p)
|
||||
else {"id": "1", "name": "KissMetrics", "name_alias": None, "email": "",
|
||||
"note_public": "OLD NOTE (pre-dossier)", "zip": None, "town": None})
|
||||
cur.update(body)
|
||||
json.dump(cur, open(p, "w"), ensure_ascii=False)
|
||||
print(json.dumps(cur, ensure_ascii=False))
|
||||
PY
|
||||
;;
|
||||
"GET /contacts"*)
|
||||
if [[ -f "${STATE}/contacts.json" ]]; then
|
||||
cat "${STATE}/contacts.json"
|
||||
else
|
||||
# Dolibarr's list endpoints answer 404 (not []) when nothing matches.
|
||||
printf '%s' '{"error":{"code":404,"message":"No contact found"}}'
|
||||
echo "stub-dol-write.sh: HTTP 404 on GET ${ENDPOINT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
"POST /contacts"*)
|
||||
printf '%s' "${BODY}" > "${STATE}/contact_post_body.json"
|
||||
python3 - "${STATE}" "${BODY}" <<'PY'
|
||||
import json, os, sys
|
||||
state, body = sys.argv[1], json.loads(sys.argv[2])
|
||||
p = os.path.join(state, "contacts.json")
|
||||
rows = json.load(open(p)) if os.path.exists(p) else []
|
||||
body = dict(body); body["id"] = "88"
|
||||
rows.append(body)
|
||||
json.dump(rows, open(p, "w"), ensure_ascii=False)
|
||||
PY
|
||||
echo "88"
|
||||
;;
|
||||
POST\ *)
|
||||
printf '%s' "${BODY}" > "${STATE}/post_body.json"
|
||||
printf '%s\n' "${ENDPOINT}" > "${STATE}/post_endpoint"
|
||||
echo "77"
|
||||
;;
|
||||
GET)
|
||||
GET\ *)
|
||||
python3 - "${STATE}/post_body.json" <<'PY'
|
||||
import json, sys
|
||||
body = json.load(open(sys.argv[1]))
|
||||
|
||||
Reference in New Issue
Block a user