User Platform Outbound Writer¶
Reference for App\Integrations\UserPlatform\UserPlatformOutboundWriter, the first writer built
against the Outbound Writer Contract that creates a record rather
than only updating one. It puts a canonical CustomerData record into a Positive User (user.com)
workspace as a contact, writes that contact's email and SMS consent onto it (see Consent),
and puts a ProductData record into the workspace as a product (see Products).
See
ADR-0031 for why it
creates its own contact rather than upserting one keyed by custom_id.
Classes¶
| Class | Holds |
|---|---|
App\Integrations\UserPlatform\UserPlatformOutboundWriter |
The verbs, the endpoints, and how the target's answers read. Extends App\Services\AbstractOutboundWriter, driver SystemDriver::UserPlatform (user-platform) |
App\Integrations\UserPlatform\UserPlatformContactMapper |
One domain: what a CustomerData looks like as a contact. Invokable, because it is one pure call and holds no state |
App\Integrations\UserPlatform\UserPlatformProductMapper |
What a ProductData looks like as a product, and the custom_id it is created under. Invokable, with the workspace's attribute names handed in |
App\Integrations\UserPlatform\UserPlatformProductSchema |
The attribute names a workspace's products already have, read off one product and cached |
App\Integrations\UserPlatform\UserPlatformErrorMessage |
Turns an error response into the message a ledger row carries (of()), shared by the writer and the schema read |
App\Integrations\UserPlatform\UserPlatformConsentMapper |
What a consent is on a contact, in both directions: callsFor() for this writer, fromContact()/fromWebhook() for the integration, and the channel list both declare |
App\Integrations\UserPlatform\UserPlatformConsentCalls |
The calls one consent costs, as data: the fields to write, the suppression flag to move, and the events to fire |
The split follows the domain. For records with a payload of their own, the writer names a domain in
exactly two places. A consent has no payload or collection of its own: performUpdate() sends it down
the consent flow before either is asked.
| Method | Answers | For customers |
|---|---|---|
payload(Projection) |
which mapping a record is put through | UserPlatformContactMapper |
collection(SystemCapability) |
which API collection that domain's records live in | users/ (products/ for products) |
Adding a domain costs a mapper class and one arm in each — no verb, no payload method on the
writer, and nothing inherited from contacts about which verbs that domain's API offers. collection()
is a match without a default arm, so a new SystemCapability is a static-analysis error here
rather than a request to a path the writer invented; the one domain Conflux does not write to this
target (orders) is refused by name. Deliberately not a resource interface with one implementation: what a
second domain needs from this class is a path and a payload, and whether it has the same three verbs
as a contact is a question about that domain's API, which a type built from this one would answer in
advance.
payload() reads the record's type rather than the projection's domain, although the question is
about the domain: Projection rejects a pair that disagrees on every construction path, so the two
are the same question, and asking it this way also narrows the record for the mapper.
Declared verbs¶
| Interface | Declared | Why |
|---|---|---|
CanCreateRemoteRecords |
Yes | Creates one contact or product per source record and keeps the numeric id the workspace assigns |
CanUpdateRemoteRecords |
Yes | Overwrites the contact or product addressed by that id with the full canonical payload |
CanDeactivateRemoteRecords |
No | The target has no inactive state of its own, for a contact or a product. Building one out of a custom attribute would mean changing somebody else's schema for a state they never asked for, and mapping it onto unsubscribed is ruled out by ADR-0016 — a record leaving a shop says nothing about what that person agreed to |
CanDeleteRemoteRecords |
Yes | Removes the contact or product addressed by that id. The API offers a hard delete only, so the record and everything the workspace recorded about it go with the call — whether a source deletion is answered with one is the rule's delete_policy, not this writer's decision |
CanIngestRemoteRecords |
No | The CSV bulk import returns no per-record identifier, so a batch taken through it would leave every contact unaddressable |
The writer never searches the workspace for an existing contact and never decides between create and update itself — the delivery ledger has already answered that from a local row before either method is called (ADR-0015).
Endpoints¶
| Verb | Method | Path | Notes |
|---|---|---|---|
| Create | POST |
/api/public/users/ |
Every field is optional; the response carries the numeric id the app assigned |
| Update | PUT |
/api/public/users/{id}/ |
Addressed by the id the create returned; overwrites the full contact |
| Delete | DELETE |
/api/public/users/{id}/ |
The same address, answered with 204. A hard delete — the only removal the app offers |
| Create a product | POST |
/api/public/products-by-id/ |
Requires custom_id; the response carries the numeric id |
| Update a product | PUT |
/api/public/products/{id}/ |
The full payload including the current custom_id |
| Delete a product | DELETE |
/api/public/products/{id}/ |
A hard delete |
| Read the product schema | GET |
/api/public/products/ |
Only the first result is used, see Products |
Authentication and rate limiting¶
| Fact | Value |
|---|---|
| Header | Authorization: Token <api_token> |
| Header | Accept: */*; version=2 (selects the API version; not acceptJson(), since User.com does not accept a standard JSON Accept header) |
| Credential | subdomain and api_token, read from SystemConnection::$credentials (encrypted) |
| Host | https://<subdomain>.<USER_PLATFORM_API_HOST>/api/public/…, composed by UserPlatformClient::endpoint(). Never base_url, which is stored and checked against the subdomain but is not the host (ADR-0028) |
| Client | Both headers and the host come from UserPlatformClient::deliveryRequest(), which also refuses a connection that is not entitled to be written to — the writer builds no request of its own |
| Rate limit | 10 requests/second, published and enforced by User.com with HTTP 429 |
requestsPerMinute() |
600 |
Field mapping¶
CustomerData → User.com contact payload, in UserPlatformContactMapper. A field the
record does not carry is left out of the payload entirely, never sent as an explicit null — the
target merges what it receives, and a null would clear a value the workspace filled in itself.
CustomerData field |
Payload field | Notes |
|---|---|---|
email |
email |
Trimmed; omitted if empty |
firstName |
first_name |
Trimmed and truncated to 40 characters |
lastName |
last_name |
Trimmed and truncated to 40 characters |
phone |
phone_number |
Normalised to E.164 via App\Services\PhoneNumbers::toE164(), read against addressBilling->country, then addressShipping->country, then the region in sourceLocale (the source shop's own locale, established at connection verification). Each candidate is narrowed to ISO 3166-1 alpha-2 before it competes — a source country is not guaranteed to be a code, and PrestaShop fills one from its localized country-name map — so an unusable value falls through instead of shutting out the candidates behind it. Narrowing checks membership, not just shape: UK and EL look like codes and are no country the library knows, and accepting either would end the chain and then fail to parse |
gender |
gender |
Unknown/Other → 1, Male → 2, Female → 3 |
tags |
tags |
Always sent as a list, including when it is empty — the one field exempt from the omit-rather-than-null rule above, because tags on a contact Conflux created are not a value the workspace owns and an omitted empty list would make the removal of a customer's last tag unrepresentable |
customAttributes |
top-level keys | Each CustomAttributeData is sent under its own key, alongside the core fields. One whose value is null is left out, under the omit-rather-than-null rule above — a shop's meta key with no value would otherwise clear whatever the workspace holds under that name |
| — | conflux_source |
Always written: the source connection's name (sourceConnectionName), which CustomerProjector already fills from the connection's name or, where it has none, its base_url host — name is nullable and no handshake path sets one, so the host is what keeps two shops on the same platform apart in practice. The platform (sourceSystem->value, e.g. woocommerce) is the last resort, for a record that names no connection at all |
Not yet reconciled with the Unified Data Model
This mapping was built against User.com's own API documentation, not against the Unified Data
Model sheet. It still needs a pass to confirm every CustomerData field maps onto the field the
sheet expects — treat the table above as provisional until that reconciliation happens.
What the payload deliberately does not write¶
| Not written | Reason |
|---|---|
custom_id |
Belongs to the workspace's own bookkeeping; reading or writing it would mean matching against contacts Conflux never created (ADR-0031) |
Any consent field (unsubscribed, email_opt_in, sms_opt_in) |
Consent is its own domain and goes out as a customer_consents update (Consent), never with the contact. The fields are reserved keys, so a custom attribute cannot set one either. Reusing a suppression flag for "record removed at source" would misrepresent a deletion as a person's objection |
| Fields the DTO does not carry | id, key, company_custom_id, sms_opt_in, and anything else the payload shape does not model in CustomerData |
Consent¶
The writer's second domain is customer_consents, a dependent domain
(ADR-0041). A consent
has no record of its own in the workspace. It is fields and events on its contact, so delivery only
ever asks for an update(), with the anchor's contact id as the remote id
(ADR-0042). The flow
follows the one User.com documents for a backend integration
(ADR-0045).
It is part of this writer rather than a second one because the registry holds one writer per driver,
and the contact and its consent are one record. UserPlatformContactMapper sends no consent, and its
reserved keys (email_opt_in, sms_opt_in, unsubscribed, unsubscribed_from_sms) keep a custom
attribute from ever overwriting one. UserPlatformConsentMapper::callsFor() sorts a consent into a
UserPlatformConsentCalls (fields, suppression flag, events), and the writer sends exactly those.
What accepts() refuses¶
| Projection | accepts() |
|---|---|
customers |
true |
customer_consents, channel email or sms, subscribed true or false |
true |
customer_consents, a channel outside supportedConsentChannels() (general) |
false |
customer_consents, subscribed: null |
false |
A refused candidate is recorded suppressed before any request and before a budget token is taken;
see accepts(). Writing an unknown subscription as false
would unsubscribe the person. writeConsent() keeps the same guard as a second line, as the shop
writers do. The check comes from the App\Services\Concerns\AcceptsConsentChannels trait. This writer
renames it to acceptsConsent() and lets every customers projection through ahead of it.
The calls per consent state¶
subscribed |
confirmed |
Calls, in this order |
|---|---|---|
true |
true |
PUT users/{id}/ {"email_opt_in": true}; mass update unsubscribed: false; event consent_granted {"email_opt_in": true, "source": …} |
true |
false or null |
event consent_requested {"requested_email_opt_in": true, "source": …} only |
false |
any | PUT users/{id}/ {"email_opt_in": false}; mass update unsubscribed: true; event consent_granted {"email_opt_in": false, "source": …} |
null |
any | nothing (refused by accepts()) |
An SMS consent takes the same branches with unsubscribed_from_sms as its only field, the SMS
suppression flag. The contact has no SMS opt-in field.
subscribed |
confirmed |
SMS calls, in this order |
|---|---|---|
true |
true |
PUT users/{id}/ {"unsubscribed_from_sms": false}; event consent_granted {"sms_consent": true, "source": …} |
true |
false or null |
event consent_requested {"requested_sms_consent": true, "source": …} only |
false |
any | PUT users/{id}/ {"unsubscribed_from_sms": true}; event consent_granted {"sms_consent": false, "source": …} |
null |
any | nothing (refused by accepts()) |
An SMS consent never touches email_opt_in or unsubscribed, and an email consent never touches
unsubscribed_from_sms. The SMS flag needs no mass update because the contact endpoint accepts it.
No shop produces SMS consent today, so this path waits for a source that does.
A withdrawal is verified on its own: confirmed describes an opt-in, and an opt-out needs no
confirmation to be honoured. An unconfirmed subscription writes no field at all. It asks Positive
User to run its own double opt-in. A shop reports confirmed: null, so every shop subscription takes
that branch.
For email, the confirmation comes back over the
consent webhook: the double opt-in sets
email_opt_in, the automation reports it, and the Positive User mirror holds the confirmed
subscription. For SMS it never comes back. All a confirmation can do on the contact is clear
unsubscribed_from_sms, and the webhook reads unsubscribed_from_sms: false as nothing, because it
is every contact's default. consent_requested is still fired for an unconfirmed SMS subscription:
an automation in the workspace may run its own SMS double opt-in on it, even though Conflux never
hears the outcome.
source is the consent's sourceSystem value (woocommerce, prestashop).
Requests¶
| Step | Method | Path | Body |
|---|---|---|---|
| Field | PUT |
/api/public/users/{id}/ |
{"email_opt_in": <bool>}, or for SMS {"unsubscribed_from_sms": <bool>} |
| Suppression (email only) | PUT |
/api/public/users/mass_update_standard_attributes/ |
{"ids": [<int id>], "attribute": "unsubscribed", "value": <bool>} |
| Event | POST |
/api/public/users/{id}/events/ |
{"name": "<event>", "data": {…}} |
{id} is the anchor id the delivery layer hands over in the RemoteReference. Every request is built
by UserPlatformClient::deliveryRequest(). No custom_id-keyed endpoint and no update_or_create is
used.
Failure semantics¶
| Outcome | Result |
|---|---|
| The first request sent answers 404 in the app's own shape | RemoteRecordGoneException; the consent row becomes remote_gone, and no later step goes out. For a confirmed or withdrawn state the first request is the field write; for an unconfirmed subscription it is the event |
| A later step answers 404 | Ordinary OutboundWriteException, whatever the body: the first request just reached the contact, so the 404 says something went wrong in between, not that the contact is gone |
| Any step answers 429 | TargetRateLimitedException, as for a contact write |
| Any step answers another non-2xx | OutboundWriteException; the retry runs the whole flow again from the start |
| A create is asked for a consent | UndeliverableRecordException. Delivery never asks; the guard keeps a consent out of the contacts collection |
One delivery costs one token of the delivery budget, while User.com counts up to three requests. A burst can therefore meet a 429 the budget would have avoided, which the rate-limit handling catches (ADR-0036).
Why unsubscribed is written as well
It is not redundant with email_opt_in. That field is what an automation reads, while
unsubscribed is what the app's own sending honours, so a withdrawal that set only the first
would leave the contact mailable. PUT users/{id}/ does not accept unsubscribed; the mass
update is the endpoint that moves it, selecting contacts by the same numeric id.
A retry repeats an event
There is no transaction across the three requests. Every call is idempotent, so a retry rewrites the same fields, but it also fires the event again. An automation that counts events rather than reading state sees it twice.
The events only do something if an automation listens for them
consent_granted and consent_requested are automation triggers. They always land in the
contact's event timeline, but they cause nothing (no double opt-in mail in particular) unless the
app has an automation bound to that event name. Conflux cannot verify this over the API; it has
to be configured per app.
consent_granted is also the event for a withdrawal
User.com documents no revocation event, and its own example payload carries a false flag inside
a consent_granted event. An automation must branch on email_opt_in in data, not on the event
name alone.
A new contact reads \"never asked\" for a while
The contact is created without consent. The consent rule, made due by the create, delivers the consent in its next chain, in the same dispatch tick or the next one. Until then the contact sends no marketing.
An address change does not ask again
A consent is delivered only when its own value changed. An address change moves the customer, not
the consent, so it fires no second consent_requested, and neither does the confirmation Positive
User reports back. The branch's memory table (user_platform_consent_deliveries) is not needed.
Products¶
The writer's third domain is products, a root domain like customers. The ledger holds the numeric
id the app assigns, and every later write addresses the product by it. The decisions behind the shape
are in ADR-0047.
The custom_id¶
POST products-by-id/ requires a custom_id and the app keeps it unique in the workspace.
UserPlatformProductMapper::customId() takes the product's SKU, or its EAN where there is no SKU,
trimmed and otherwise as the shop holds it. The EAN is ProductData::$ean, or the shop's custom
attribute named ean where the field is empty. A product with neither is refused with an
UndeliverableRecordException: the ledger row is undeliverable, last_error says what to fill in,
and the rule's delivery records in the admin panel show it.
Every update sends the current custom_id as well, so a SKU changed at the source renames the remote
product in place.
Only products Conflux created¶
The app answers 400 {"custom_id": ["product with this Custom ID already exists."]} when another
product holds the key, on a create and on a rename. The writer turns that into an
UndeliverableRecordException that names the key. Nothing is merged into the other product. Two
source products with the same SKU end up as one delivered and one undeliverable.
An update the app answers with 404¶
A product deleted in the workspace is not created again. An update answered with the app's own 404
(a JSON body carrying detail, or no body) is a RemoteRecordGoneException, exactly as for a
contact: the row becomes remote_gone and delivery of that product stops (ADR-0015). An HTML error
page stays an ordinary, retryable failure.
The payload¶
| Part | Written |
|---|---|
| Model fields | custom_id, name, description, product_url, image_url, brand_name, target_group, created_at, updated_at, each when the record carries a value |
conflux_source |
Always: the source connection's name (sourceConnectionName), else its host, else the platform |
| Fixed attributes | sku, ean, price, original_price, currency, status, only where the workspace's products already have an attribute of that name. price and original_price are decimals in major units as strings ("899.00"), converted from minor units |
| The shop's custom attributes | Each under its own key, only where the workspace's products already have it. A null value is left out |
| Never written | Categories and tags |
The app turns every key a product is written with into an attribute of every product in the workspace, so writing what the shop holds would rewrite the workspace's product schema. A product attribute is therefore written only where the workspace already has it.
UserPlatformProductSchema reads the names from GET products/: the keys of the first result, minus
the model's own keys. The answer is cached for 300 seconds per connection and subdomain, so an
attribute the workspace owner just created is seen after at most five minutes. A workspace with no
product yet answers an empty list. A schema read that fails fails the write as a retryable
OutboundWriteException.
A shop custom attribute whose key collides with a reserved key (the model fields, the fixed
attributes, conflux_source and the app's own keys) is refused as undeliverable, naming the key.
Deletion¶
DELETE products/{id}/ is a hard delete, because the app has no inactive state for a product either.
The seeded Positive User products direction is push_out, with delivery_defaults of overwrite
and delete. A mass deletion is held by the volume breaker like any other withdrawal
(ADR-0035).
What a deletion at the source can do here¶
delete is declared, deactivate is not, so a rule has two outcomes to choose between for this
target: remove the contact, or leave it untouched. There is no third state in between — the workspace
has no inactive state of its own, and Conflux does not invent one in somebody else's schema.
The removal is the app's hard delete, the only one it offers: the contact and everything the
workspace recorded about it are gone, and nothing Conflux holds can put either back. Declaring the
verb says the target can delete, not that deleting is the right answer to a source record going
away — that is the rule's delete_policy
(ADR-0016), and a rule that leaves it at ignore
never reaches the verb.
deactivate stays out because the only way to build the state it needs is a custom attribute the
workspace would have to create before the first delivery, and reusing unsubscribed for it is ruled
out by ADR-0016: a record leaving a shop says nothing about what that person agreed to, so an
ordinary removal must not read as an objection.
A reference naming a domain other than customers or products is refused with an UndeliverableRecordException
before the request leaves. A delete is handed a bare reference with no projection beside it, so that
check is the only thing between a wrong domain and a DELETE on whichever contact happens to hold
that number.
Open against the epic
Push-Out Epic B lists deactivate as needing its own custom attribute, and ADR-0016 assumes a
target with a lifecycle state can offer the deactivate policy. This writer declares neither,
for the reasons above. The contradiction is recorded rather than resolved here.
Response handling¶
| Outcome | Result |
|---|---|
PUT (update) returns 404 in the app's own shape — a body carrying detail ({"detail": "Not found."}), or no body at all |
RemoteRecordGoneException — the numeric id User.com assigned is never reissued, so this is unambiguous. A contact or product deleted in the workspace is not created again |
POST or PUT of a product returns 400 with custom_id naming a key that already exists |
UndeliverableRecordException naming the key, see Products |
PUT (update) returns 404 with any other body |
Ordinary OutboundWriteException — a proxy's or a framework's error page means the request never reached the contact resource, and reading that as gone would stop delivering every record of the run for good. JSON is not the test: a WAF answers a blocked path with {"message": "Not Found"} as readily as with HTML, so only the shape separates the two |
DELETE returns 404 in the app's own shape |
RemoteRecordGoneException — somebody removed the contact in the workspace first, which is where a successful delete leaves the record too: the ledger marks it remote_gone and stops delivering it |
DELETE returns 404 with any other body |
Ordinary OutboundWriteException, for the same reason as the update row above — a request that never reached the contact resource says nothing about whether the contact is still there |
POST (create) returns 404 |
Ordinary OutboundWriteException — there is no contact yet, so a 404 here means a wrong base URL or a workspace that no longer exists, not a deleted record |
POST (create) succeeds but the response carries no usable id |
UnaddressableRemoteRecordException — the contact exists in the workspace and nothing points at it; retrying would create a second unreachable contact rather than repair the first. It carries ProvesIntegrationReachable: the workspace accepted the write, so the circuit stays closed rather than opening on a response shape the writer could not read |
| Any request returns 429 | TargetRateLimitedException, carrying retry-after parsed as whole seconds. A missing or non-numeric header gives retryAfterSeconds: null |
| Any other non-2xx response | OutboundWriteException, carrying the parsed error message (see below). A refusal the workspace made is an ordinary write failure even when it names the field it disliked — where the refusal was decided is what separates it from UndeliverableRecordException |
$remoteId is what separates the two readings of a 404: passed for update and delete, null for
create. The body separates them a second time — see the two update rows above.
Connections the writer refuses to deliver to¶
requireDeliverableConnection() (the AbstractOutboundWriter hook) asks
UserPlatformClient::deliveryRequest() before the write enters the circuit breaker and the error
mapping. Both of its refusals therefore keep their own type and travel to the caller untouched:
| Condition | Exception |
|---|---|
| The connection's status no longer entitles it to deliveries (uninstalled, disconnected, inactive) | IntegrationRequestException |
Credentials the workspace cannot be addressed with — a missing or malformed subdomain, a token that is not 64 characters, a base_url naming a different host |
UnusableCredentialsException |
Neither is an OutboundWriteException and neither counts a circuit-breaker failure, which is the
point of checking them there. Raised inside the breaker's callback — which is what happens when a
writer only asks for its request inside performCreate()/performUpdate() — they would be rewrapped
as retryable write failures, so a rotated token would put every record of a run on a retry schedule
that cannot succeed and open the circuit over requests that never left.
Error message parsing¶
errorMessage() tries three response shapes, in order, then falls back to the raw body:
- A single string under
detail,errorormessage. - A field-to-messages map (what validation failures answer with) — folded into
field: messagepairs joined by;. This is what makes a rejected custom attribute legible: User.com names the offending key. - A bare list of messages — folded the same way, without a field prefix.
Where none of the three matches, the message is the raw response body, trimmed and truncated to
UserPlatformOutboundWriter::RAW_MESSAGE_LIMIT (1000) characters, or no message where the body is
empty. The cap is what keeps a full HTML error page out of last_error, a text column an operator
reads the first line of.
If the response carries a retry-after header (User.com sends it lowercase; header() compares
case-insensitively), its value is appended to the message as (retry after {n}s) — so a rate limit
carries its wait into the ledger's last_error, which is where an operator looks first. On a 429
the same value also travels as TargetRateLimitedException::$retryAfterSeconds, so the delivery
layer reads it without parsing the message a second time.
Records the writer refuses to deliver¶
Each raises App\Exceptions\UndeliverableRecordException, not OutboundWriteException: the
source row has to change first, so a retry asks the same question of the same row forever. The
exception carries ReportsARecordFault, so the circuit breaker leaves its state untouched — a batch
holding a few of these must not take a healthy connection down
(ADR-0032).
| Condition | Note |
|---|---|
Neither email nor a phone number that normalises to E.164 |
The contact would have no addressee in either channel. A phone-only customer is delivered normally; SMS is a channel like any other |
A custom attribute whose key collides with a reserved payload field (email, custom_id, unsubscribed, conflux_source, etc. — see UserPlatformContactMapper::RESERVED_KEYS) |
The message names the offending key. A collision is refused rather than silently dropped, because a dropped attribute looks exactly like one the shop never sent |
A product with neither a SKU nor an EAN, a shop attribute colliding with a product's reserved key, or a custom_id another product in the workspace already holds |
See Products |
A projection whose domain is none of customers, products and, on an update, customer_consents |
Mis-routed before it reached the writer. A create for a consent is refused the same way |
Workspace prerequisites¶
The custom attribute conflux_source has to be declared in the target workspace before the first
delivery, because it travels on every payload. Connecting declares it — see
Declaring conflux_source — so a workspace
that went through the handshake needs no preparation by hand.
It matters that this is handled rather than documented as a step, because the app gives no sign when
it is missing: a contact carrying an attribute the workspace has not declared is accepted with 201
and the value is dropped without a word. A workspace nobody prepared would hold contacts with no
source marker and nothing would fail to reveal it. A workspace this writer reaches without ever
having been connected — a live test pointed at a fresh one, say — is the one case left where the
attribute has to be declared by hand.
Test coverage¶
| Tier | File | Covers |
|---|---|---|
| Unit | tests/Unit/Integrations/UserPlatform/UserPlatformOutboundWriterTest.php |
Declared capabilities, create/update against Http::fake(), the full field mapping, records refused, error message parsing across all three response shapes |
| Unit | tests/Unit/Services/PhoneNumbersTest.php |
PhoneNumbers::toE164() in isolation — national and international numbers, absent input, unresolvable input, non-ISO region values |
| Unit | tests/Unit/Integrations/UserPlatform/UserPlatformConsentMapperTest.php |
callsFor() per consent state |
| Unit | tests/Unit/Integrations/UserPlatform/UserPlatformProductMapperTest.php |
The product payload, custom_id from SKU or EAN, attributes written only where the workspace has them, reserved keys refused |
| Unit | tests/Unit/Integrations/UserPlatform/UserPlatformOutboundWriterProductTest.php |
Product create/update/delete against Http::fake(): the numeric id kept, the rename, the taken key on create and rename, a 404 the workspace answered read as gone, the attribute read and its cache |
| Integration | tests/Integration/Integrations/UserPlatform/UserPlatformOutboundWriterConsentTest.php |
The consent flow against Http::fake(): requests and their order, accepts(), the gone signal on the first request only, failures on each step, and the refused create |
| Feature (e2e) | tests/Feature/Outbound/ConsentFlowE2eTest.php |
The three consent flows through the real chain, with only outbound HTTP faked |
| Feature (e2e) | tests/Feature/Outbound/UserPlatformProductDeliveryE2eTest.php |
Product delivery through the real chain with only outbound HTTP faked: create under the SKU, rename on update, a product with neither SKU nor EAN, a taken key, and a mass deletion held by the volume breaker and deleted once released |
| Feature (e2e) | tests/Feature/Outbound/UserPlatformCustomerDeliveryE2eTest.php |
The real chain — persisted Customer, the customers projector, OutboundWriterRegistry, this writer — with only the outbound HTTP call faked. Asserts both the returned reference and the request sent |
| Live | tests/Live/UserPlatformLiveConsentWriteTest.php |
That email_opt_in is kept, that consent_granted answers 2xx on the numeric id, that the mass update moves unsubscribed both ways, and that a deleted contact reads as gone. Never fires consent_requested; see the consent tiers |
| Live | tests/Live/UserPlatformLiveProductWriteTest.php |
The product assumptions of ADR-0047 against a real workspace: create returns a numeric id, a PUT with a changed custom_id renames the product, a taken key is refused on create and rename and the other product is left alone, an update of a product deleted in the workspace is reported as gone and nothing is created, an attribute the workspace's products lack is not created, and a delete removes the product |
| Live | tests/Live/UserPlatformLiveCustomerWriteTest.php |
The assumptions that fail silently against a real workspace: that create returns a usable numeric id, that phone_number/gender are taken under those names, that a declared custom attribute is kept while an undeclared one is dropped in silence, that a 404 on update or delete means gone rather than recreated, and that a delete really removes the contact |
Running the live tests¶
The live tier needs a real User.com workspace with conflux_source declared in it — connecting
declares it, so this only has to be done by hand for a workspace that has never been connected.
Configure it with:
docker compose exec \
-e USER_PLATFORM_LIVE_SUBDOMAIN=<workspace subdomain, without the apex> \
-e USER_PLATFORM_LIVE_API_TOKEN=<64-char token> \
php composer test:live -- --filter UserPlatformLiveCustomerWrite
| Env var | Purpose |
|---|---|
USER_PLATFORM_LIVE_SUBDOMAIN |
The workspace under test, without the apex |
USER_PLATFORM_LIVE_API_TOKEN |
That workspace's 64-character API token |
The same pair UserPlatformLiveVerificationTest takes — it is the same credential in two parts.
USER_PLATFORM_API_HOST has to be set in the container's environment as well, since the apex that
completes the host has no default.
Both unset skips the whole file. The run creates real contacts and deletes each one again through
the app's hard delete in afterEach(); every contact it creates carries conflux_source = live-test,
which is what makes one left behind by a crashed run findable. See
Run the Live Tests for the shared conventions
this tier follows.
See also¶
- Outbound Writer Contract — the capability interfaces, traits, exceptions and registry this writer is built against
- ADR-0031 — why the
writer creates and updates by numeric id rather than upserting by
custom_id - ADR-0047 — how a product is keyed, addressed and written
- ADR-0045 — the consent flow, and why it lives in this writer
- ADR-0015 — why Conflux creates a record in the target rather than resolving one
- ADR-0021 — why
deactivateand bulk ingestion are simply absent from the type rather than refused at runtime - ADR-0032 — why a record this writer refuses is neither retried nor counted against the connection's health
- Implement an Outbound Writer — the general how-to, now with this writer as the worked example