Skip to content

feat(customerprofile): band customer-profile end to end (PR 2 of 5) - #32

Open
kshahbw wants to merge 17 commits into
feat/reg-center-10dlc-directfrom
feat/customer-profiles
Open

feat(customerprofile): band customer-profile end to end (PR 2 of 5)#32
kshahbw wants to merge 17 commits into
feat/reg-center-10dlc-directfrom
feat/customer-profiles

Conversation

@kshahbw

@kshahbw kshahbw commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

BLUF

band customer-profile end to end — create, list, get, update, delete, restore, history list, history get. Second of five PRs adding 10DLC Registration Center support.

Stacked on #27. Review that first; this branch contains its commits.

A customer profile is a hard prerequisite for 10DLC brand registration, and a profile backs exactly one brand — so every brand needs a fresh one. The prerequisite chain is customer profile → brand → campaign.

Why this one is first

PR 1 was a horizontal slice — all plumbing, one visible command. That produced primitives designed against imagined consumers. The remaining PRs are sliced vertically instead: one resource, end to end, per PR.

Customer profiles go first because they're the smallest resource that exercises every shared pattern PRs 3–5 will reuse, against something real:

  • api.ForEachPage — one paging loop instead of four hand-rolled ones
  • typed option structs with Validate / Build*Request separated from flag parsing
  • the read-modify-write overlay
  • --confirm gating for destructive operations
  • receipt vocabularies
  • --all pagination and deepObject filter flags

The decision this PR settles — closes #26

The API's PUT is a full replacement: any field absent from the body is nulled server-side. The published spec documents no request body at all for the update operation, so this was measured.

Building the payload from a typed struct would therefore silently delete every production field the CLI doesn't model. That's the same data loss the lossless-response decision exists to prevent, moved to write time — and worse, because it destroys customer data rather than hiding it.

Resolution: validate typed, emit from the read map. update GETs the profile, deep-copies the returned map, strips read-only fields, overlays only explicitly-changed flags, and PUTs that. Typed validation going in, losslessness coming out — a field nobody has heard of survives because it was never removed.

Verified live, not just in tests: setting website, then renaming with --name alone, leaves website intact. Same for a phone-only contact update preserving the contact's name and email.

Measured API behavior this is built on

Every item below contradicts or exceeds the published spec. All probed against account 9901287.

Behavior Reality
PUT semantics Full replacement — an omitted field is nulled
version on PUT Required, despite being readOnly in the schema. Omitting it returns the same 409 as a stale version
Clearing a field Send JSON null. Empty strings are rejected (size must be between 1 and 500)
Restore {"softDeleted": false}. The documented {"deleted": false} returns 404 "Customer profile not found" while GET returns the record
Soft-delete flag softDeleted on every read; no deleted field exists
History entries {data, metadata} envelope, newest-first; version is at metadata.version
Restore in history Logged as operation: CREATED, so an audited profile shows two creations
Profile ↔ brand Strictly 1:1; reuse fails with cannot be assigned to another brand

Reported upstream as MV-23429.

Verification

go build, go vet, full suite, and the doc-contract test all pass. knownDrift untouched.

Full live end-to-end on 9901287, nine steps:

  1. create → 2. set website → 3. rename only, website survives → 4. phone-only contact update, name and email survive → 5. --website "" sends null, name preserved → 6. delete without --confirm exits 6 with zero stdout; with it, deleted:true and the record stays retrievable at softDeleted:true → 7. soft-deleted excluded from list, still returned by get → 8. restore → softDeleted:false → 9. history returns 8 entries newest-first; history get 0 returns the original CREATED

All throwaway profiles cleaned up.

Agent-facing contracts

  • list / history list always return arrays; get / history get always return objects. Shape never depends on argument count.
  • --confirm is a flag, never a prompt — no TTY check, so agents and humans get one contract. Missing it fails client-side: exit 6, zero HTTP requests, nothing on stdout.
  • A missing Customer Profiles Access role maps to exit 4 with an actionable message, not exit 2 ("reauth"), because re-authenticating will never fix it.
  • A concurrent edit between the read and the write is caught by the API's version check and exits 4 — retry.
  • Truncated pages warn on stderr; stdout stays pure JSON.
  • create is non-idempotent and has no --if-not-exists: a profile has no safe natural key, and reusing one would link an old brand's profile to new customer data. AGENTS.md's blanket --if-not-exists principle was amended to match, since this is the first shipped create without a key.

Known gaps, deliberately deferred

  • The pager.go infinite-loop guard message says "N total elements remaining" where N is the grand total, overstating when some pages were already read. Cosmetic, on a rare inconsistency path — fix opportunistically in PR 3 when --all gains rate-limit pacing.
  • Update validation also runs on restore, so a profile whose contact lacked a name couldn't be restored. Unreachable in practice: the API schema marks contact.name required, so such a profile cannot exist.
  • End-to-end 403 tests exercise the GET step but not the PUT step for update/restore (identical composition, covered by unit tests).

Related

kshahbw added 17 commits August 17, 2026 16:43
Nested maps like contact were aliased between the returned update body
and the caller's current resource. Deep-copy them so mutating one
cannot silently corrupt the other, and add coverage for the aliasing
case and for partial contact updates.
…harness

Replaces test-only PersistentFlags on Cmd and a bespoke stdout-capture
closure with the shared internal/testutil helpers already used by
cmd/tendlc and cmd/sip. runCmd now wraps Cmd in a single package-level
testRoot built once via testutil.NewTestRoot, reused (not rebuilt) on
every call to avoid cobra's ancestor-flag cache going stale across
repeated executions of the same package-level command.
TestRunCmdRootFlagsSurviveAcrossCalls makes two sequential runCmd calls
and asserts the second one's --format table flag actually took effect.
cobra caches a command's merged ancestor flags on first parse and never
refreshes that cache for a different root object, so building a fresh
testutil.NewTestRoot(Cmd) per call would silently drop the root's flags
from the second call onward with no other test in this file able to
notice, since create/list/get's payloads are already flat and render
identically whether --plain is honored or not.
…n update

The API rejects an empty string on fields like website ("size must be
between 1 and 500") but accepts and applies JSON null, measured against
production. The documented way to clear a field is passing the flag with
an empty value, so that value must reach the server as null.
@kshahbw
kshahbw requested review from a team as code owners August 18, 2026 15:43
@bwappsec

bwappsec commented Aug 18, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants