Repository navigation
Support overrides and historical values on customer, contact and custom attribute endpoints - #162
Conversation
keith-chartmogul
left a comment
There was a problem hiding this comment.
LGTM, aside from a slight misalignment in the tests, which I noted with an inline comment. It would be great to address that if you can, but not the end of the world if you choose to ignore. 👍
26069c5 to
384c1a8
Compare
keith-chartmogul
left a comment
There was a problem hiding this comment.
I found some more opportunities for improvement, specifically in the tests and documentation, so not strictly needed but I figure it is worth improving things as much as we can right now ahead of the release. 💪
| overrides: { company: true, attributes: { custom: { channel: true } } }, | ||
| historical_values: { | ||
| company: [ | ||
| { value: 'Pinata Technologies', update_performed_at: '2026-01-01T12:00:00Z', update_performed_by: 'adam@smith.com', initial: false } |
There was a problem hiding this comment.
Small fixture accuracy point: the API never returns an email in update_performed_by. EditHistories::EntryBuilder#performed_by returns the user's name, "API" for API-key writes, or the data source name for external updates. Could we change this (and the one in test/chartmogul/contact.js:330) to something like 'API' or a sample name? The tests still pass either way, but these fixtures double as documentation of the response shape.
| ChartMogul.Customer.retrieve(config, customerUuid) | ||
| ChartMogul.Customer.retrieve(config, customerUuid, { | ||
| with_overrides: true, | ||
| attributes_with_history: 'company,custom.channel' |
There was a problem hiding this comment.
These comments also apply to lines 174 and 374 in this file
Worth a short note here that attributes_with_history has to be a comma-separated string. Passing an array (which feels natural in JS) silently returns the wrong result:
ChartMogul.Customer.attributes(config, uuid, { attributes_with_history: ['custom.a', 'custom.b'] })
// GET .../attributes?attributes_with_history=custom.a&attributes_with_history=custom.bRack keeps only the last value, so the caller gets history for custom.b and nothing for custom.a, with no error. It's also capped at 100 names; more returns a 422. Something like:
// attributes_with_history: comma-separated attribute names (max 100), e.g. 'company,custom.channel'There was a problem hiding this comment.
Added 2445b1a minus cap which isn't public and the example is already there in the code snippet. 👌
| 'custom': [ | ||
| { 'type': 'String', 'key': 'channel', 'value': 'Facebook' } | ||
| ], | ||
| 'overrides': { 'custom': { 'channel': true } } |
There was a problem hiding this comment.
Could we add a line of prose about the overrides shape? It differs between endpoint families, and the examples alone make that easy to miss:
Customer.create/Customer.modify: custom attributes sit underattributes, e.g.{ company: true, attributes: { custom: { channel: true } } }CustomAttribute.add/update/remove(and theCustomer.attributesresponse):customsits directly underoverrides, e.g.{ custom: { channel: true } }CustomAttribute.addby email: the payload uses the direct shape, but eachentries[].overridesin the response uses the customer shape with theattributeswrapper
The by-email variant with overrides is covered in test/chartmogul/custom-attribute.js:102 but has no README example. Worth adding one next to this?
| @@ -393,6 +413,13 @@ ChartMogul.CustomAttribute.update(config, customerUuid, { | |||
| ChartMogul.CustomAttribute.remove(config, customerUuid, { | |||
There was a problem hiding this comment.
Two behaviors that SDK users will likely hit and that aren't documented yet:
falseunpins. On an update or remove,falsereleases the pin and restores the latest edit-history value. On a create it's a no-op.- Deleting a pinned custom attribute keeps it unless the same call unpins it. The request still returns 202, but
messagebecomesCustom attributes deleted from customer, except overridden: XorCustom attributes are overridden and were not deleted from customer: X.
An unpin-and-remove example would cover both:
// Pinned attributes are kept on remove unless unpinned in the same call; check `message`
ChartMogul.CustomAttribute.remove(config, customerUuid, {
'custom': ['channel'],
'overrides': { 'custom': { 'channel': false } }
});| ChartMogul.Contact.modify(config, contactUuid, data) | ||
| ChartMogul.Contact.modify(config, contactUuid, { | ||
| title: 'CEO', | ||
| overrides: { title: true } |
There was a problem hiding this comment.
Could we note that contacts only accept overrides for standard fields? A contact custom attribute in overrides is rejected with a 422 (is not an overridable attribute), because Contacts::OverridesService has no custom attribute pins. Someone extrapolating from the customer examples would probably try overrides: { custom: { ... } } here.
| }); | ||
| }); | ||
|
|
||
| it('should retrieve customer attributes with a callback as the third argument', (done) => { |
There was a problem hiding this comment.
Two small things on the callback test:
- The four-argument form,
Customer.attributes(config, uuid, params, callback), isn't covered. Only the three-argument callback form and the promise form are. A case with both params and a callback would protect the new signature from both directions. - Nit: AGENTS.md asks for tests that return promises rather than using
done. This one could be:
it('should retrieve customer attributes with a callback as the third argument', async () => {
// ...nock setup
const res = await new Promise((resolve, reject) => {
Customer.attributes(config, customerUuid, (err, body) => (err ? reject(err) : resolve(body)));
});
expect(res).to.have.property('tags');
});| expect(contact.external_id).to.be.equal('contact_external_id_002'); | ||
| }); | ||
|
|
||
| it('updates a contact with overrides', async () => { |
There was a problem hiding this comment.
Could we add one error-path test for overrides? The most likely real-world failure is a 422 from override validation, such as pinning a contact custom attribute or sending a non-boolean flag. A test would confirm that callers get a ResourceInvalidError with the API's details on err.response:
it('rejects overrides on contact custom attributes', async () => {
const contactUuid = 'con_00000000-0000-0000-0000-000000000000';
/* eslint-disable camelcase */
const patchBody = { overrides: { custom: { channel: true } } };
const errorBody = {
errors: { 'overrides[custom]': 'is not an overridable attribute' },
error_details: [{ param: 'overrides[custom]', code: 'not_overridable', message: 'is not an overridable attribute' }]
};
/* eslint-enable camelcase */
nock(config.API_BASE)
.patch(`/v1/contacts/${contactUuid}`, patchBody)
.reply(422, errorBody);
const err = await Contact.modify(config, contactUuid, patchBody).then(
() => { throw new Error('expected rejection'); },
e => e
);
expect(err).to.be.instanceOf(ChartMogul.ResourceInvalidError);
expect(err.response).to.eql(errorBody);
});(The explicit throw matters: the existing .catch-only pattern around customer.js:95 passes even when the promise resolves.)
There was a problem hiding this comment.
Added 354eed6, but Claude friend found some more general issue about superagent?
Added the test with your explicit-throw guard — but with the assertions adapted, because the suggested ones exposed something pre-existing:
ResourceInvalidError(and the whole typed 4xx mapping inresource.js) has been unreachable since the superagent migration. Superagent errors on any 4xx andresource.jsrejects that error before the status switch runs — the existing "should throw NotFoundError/ResourceInvalidError" tests intest/chartmogul/resource.jsactually assert superagent's error shape (e.status,e.response), not the typed classes. So the SDK's de facto error contract today is superagent errors, and the new test pins that:err.status === 422anderr.response.bodycarrying the API'serrors/error_details. Restoring the typed mapping would change the rejected error's shape for every consumer, which is a breaking change out of scope for this additive PR — I'd suggest a separate issue for it.
| }); | ||
| }); | ||
|
|
||
| it('should remove custom attributes from a customer with overrides', () => { |
There was a problem hiding this comment.
Optional: a second remove case where the attribute stays pinned (no false override) and the 202 reply carries message: 'Custom attributes are overridden and were not deleted from customer: CAC' would record that the 202 can hide a no-op. That's probably the most surprising behavior for SDK users.
Adds support for the overrides and historical values API to the Node client, targeting the current API where write responses always include
overrides.overridesalready works onCustomer.create/modify,Contact.create/modify,Customer.createContactand theCustomAttributehelpers, andCustomer.retrieve/Contact.retrievealready accept a trailing query object forwith_overridesandattributes_with_history. Tests now pin all of that: nock body matching on every write, query serialization on retrieves, and assertions thatoverrides,historical_values(including entry fields) and the DELETEmessagesurvive in responses.Customer.attributes(config, customerUuid)hardcoded an empty query; it gains an optional params argument (Customer.attributes(config, customerUuid, { with_overrides: true })), backward compatible with a callback in third position.falseremoves an override and restores the most recent edit-history value; remove keeps overridden attributes and reports them inmessage), the shape difference between the customer and custom attribute endpoints (including the by-email response asymmetry), thatattributes_with_historymust be a comma-separated string rather than an array, and that contact custom attribute overrides are not yet implemented by the API.update_performed_byis a user name,"API"or a connector name — never an email). New cases cover the params-plus-callback form ofCustomer.attributes, the 422 from contact custom attribute overrides with the error body callers actually get, and the 202 no-op where remove keeps an overridden attribute.