4b4f6f2071
Plain sends `customer.externalId` as an explicit `null` rather than
omitting the key. The schema validated it with `z.string().optional()`,
which accepts `undefined` but rejects `null`, so every customer we don't
set an `externalId` for got a 400 instead of a card — while the rest
worked, which made it look intermittent.
`email`, `externalId` and `thread` are now `nullish`. One of
email/externalId is still required, and the route's existing email
fallback resolves these customers.
Three related fixes in the same path:
- The route returned `{ cards: [] }` when no user matched. Plain records
an integration error for any requested key it doesn't get back, so that
surfaced as a broken card rather than a hidden one. Every requested key
is now answered, with `components: null` where there's no data.
- The impersonation link is offered only when the customer matched on
`externalId` — a value we set ourselves. An email match is a weaker
claim, since the address on a Plain customer isn't verified and for
customers created outside our own writes it comes from whoever sent the
message. Email-matched customers get the account rows without a
one-click impersonation link.
- The not-found log recorded raw customer identifiers; it now keeps
presence flags only.
The schema and the response helper moved to
`app/utils/plainCustomerCards.ts` so they can be unit-tested without
pulling in the db and env modules.
## Testing
`app/utils/plainCustomerCards.test.ts` — 11 tests covering the null
shapes, the every-key-answered response, and the missing-vs-zero
distinction. Verified locally.
Split out of #4571, which bundled this with an unrelated impersonation
fix.
129 lines
4.6 KiB
TypeScript
129 lines
4.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
answerAllCardKeys,
|
|
emailLookupCandidates,
|
|
PlainCustomerCardRequestSchema,
|
|
} from "./plainCustomerCards";
|
|
|
|
const request = (overrides: Record<string, unknown> = {}) => ({
|
|
cardKeys: ["account-details"],
|
|
customer: { id: "c_1", email: "dev@example.com", externalId: "user_1" },
|
|
...overrides,
|
|
});
|
|
|
|
describe("PlainCustomerCardRequestSchema", () => {
|
|
it("accepts a fully populated request", () => {
|
|
expect(
|
|
PlainCustomerCardRequestSchema.safeParse(request({ thread: { id: "th_1" } })).success
|
|
).toBe(true);
|
|
});
|
|
|
|
// Plain sends explicit nulls rather than omitting these keys. Rejecting them meant every
|
|
// customer created outside our own writes got a 400 instead of a card.
|
|
it("accepts a null externalId when there is an email", () => {
|
|
const result = PlainCustomerCardRequestSchema.safeParse(
|
|
request({ customer: { id: "c_1", email: "dev@example.com", externalId: null } })
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("accepts a null email when there is an externalId", () => {
|
|
const result = PlainCustomerCardRequestSchema.safeParse(
|
|
request({ customer: { id: "c_1", email: null, externalId: "user_1" } })
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("accepts a null thread", () => {
|
|
expect(PlainCustomerCardRequestSchema.safeParse(request({ thread: null })).success).toBe(true);
|
|
});
|
|
|
|
it("accepts an omitted thread", () => {
|
|
expect(PlainCustomerCardRequestSchema.safeParse(request()).success).toBe(true);
|
|
});
|
|
|
|
// A contact created by an integration can have neither identifier. There's nothing to look up,
|
|
// but rejecting it would make Plain record an integration error rather than hide the card.
|
|
it("accepts a customer with neither email nor externalId", () => {
|
|
const result = PlainCustomerCardRequestSchema.safeParse(
|
|
request({ customer: { id: "c_1", email: null, externalId: null } })
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("rejects a body with no card keys field", () => {
|
|
expect(PlainCustomerCardRequestSchema.safeParse({ customer: { id: "c_1" } }).success).toBe(
|
|
false
|
|
);
|
|
});
|
|
});
|
|
|
|
// `User.email` casing depends on the signup path: the SSO upsert lowercases, magic-link and OAuth
|
|
// store what the provider gave. Either candidate alone misses one of those populations.
|
|
describe("emailLookupCandidates", () => {
|
|
it("tries the address as sent before its lowercased form", () => {
|
|
// Finds a magic-link user stored with capitals, then an SSO user stored lowercased.
|
|
expect(emailLookupCandidates("Dev@Example.com")).toEqual([
|
|
"Dev@Example.com",
|
|
"dev@example.com",
|
|
]);
|
|
});
|
|
|
|
it("yields a single candidate when the address is already lowercase", () => {
|
|
expect(emailLookupCandidates("dev@example.com")).toEqual(["dev@example.com"]);
|
|
});
|
|
|
|
it("trims before comparing, so padding doesn't produce a duplicate candidate", () => {
|
|
expect(emailLookupCandidates(" dev@example.com ")).toEqual(["dev@example.com"]);
|
|
});
|
|
|
|
it("is empty for absent or blank addresses, so the lookup can be skipped", () => {
|
|
expect(emailLookupCandidates(null)).toEqual([]);
|
|
expect(emailLookupCandidates(undefined)).toEqual([]);
|
|
expect(emailLookupCandidates("")).toEqual([]);
|
|
expect(emailLookupCandidates(" ")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("answerAllCardKeys", () => {
|
|
it("adds a no-data card for every unanswered key", () => {
|
|
expect(answerAllCardKeys(["a", "b"], [])).toEqual([
|
|
{ key: "a", components: null, timeToLiveSeconds: 60 },
|
|
{ key: "b", components: null, timeToLiveSeconds: 60 },
|
|
]);
|
|
});
|
|
|
|
it("leaves answered cards untouched", () => {
|
|
const answered = { key: "a", components: [{ componentText: { text: "hi" } }] };
|
|
|
|
expect(answerAllCardKeys(["a"], [answered])).toEqual([answered]);
|
|
});
|
|
|
|
it("fills only the gaps, keeping answered cards first", () => {
|
|
const answered = { key: "b", components: [] };
|
|
|
|
expect(answerAllCardKeys(["a", "b", "c"], [answered])).toEqual([
|
|
answered,
|
|
{ key: "a", components: null, timeToLiveSeconds: 60 },
|
|
{ key: "c", components: null, timeToLiveSeconds: 60 },
|
|
]);
|
|
});
|
|
|
|
// Omitting the TTL would fall back to the card's configured default, keeping an empty card in
|
|
// Plain's cache after the customer becomes resolvable.
|
|
it("caps how long an empty card is cached", () => {
|
|
const [filler] = answerAllCardKeys(["a"], []);
|
|
|
|
expect(filler).toMatchObject({ timeToLiveSeconds: 60 });
|
|
});
|
|
|
|
it("ignores extra cards that were not requested", () => {
|
|
const extra = { key: "unrequested", components: [] };
|
|
|
|
expect(answerAllCardKeys([], [extra])).toEqual([extra]);
|
|
});
|
|
});
|