Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/builder/__tests__/capi-connection-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,37 @@ describe("getCapiConnectionState", () => {
}),
).toBe("disconnected")
})

test("dataset saved but scope missing and no manual token awaits scope", () => {
expect(
getCapiConnectionState({
capiDisconnected: false,
hasManualCapiAccessToken: false,
hasCapiScope: false,
hasDatasetId: true,
}),
).toBe("awaitingScope")
})

test("user disconnect overrides an awaiting-scope dataset", () => {
expect(
getCapiConnectionState({
capiDisconnected: true,
hasManualCapiAccessToken: false,
hasCapiScope: false,
hasDatasetId: true,
}),
).toBe("disconnected")
})

test("manual token with dataset stays connectedCustom even without scope", () => {
expect(
getCapiConnectionState({
capiDisconnected: false,
hasManualCapiAccessToken: true,
hasCapiScope: false,
hasDatasetId: true,
}),
).toBe("connectedCustom")
})
})
107 changes: 107 additions & 0 deletions apps/builder/__tests__/capi-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, test } from "vitest"
import { getCapiStatus } from "@/features/meta-conversions/lib/capi-status"

describe("getCapiStatus", () => {
test("unsupported wins over every other input", () => {
expect(
getCapiStatus({
hasCapiScope: true,
hasManualCapiAccessToken: true,
hasDatasetId: true,
credentialAvailable: true,
supported: false,
}),
).toBe("unsupported")
})

test("manual token with dataset is ready", () => {
expect(
getCapiStatus({
hasCapiScope: false,
hasManualCapiAccessToken: true,
hasDatasetId: true,
credentialAvailable: true,
}),
).toBe("ready")
})

test("oauth scope with dataset is ready", () => {
expect(
getCapiStatus({
hasCapiScope: true,
hasManualCapiAccessToken: false,
hasDatasetId: true,
credentialAvailable: true,
}),
).toBe("ready")
})

test("credential unavailable is unverified even with a dataset saved", () => {
expect(
getCapiStatus({
hasCapiScope: false,
hasManualCapiAccessToken: false,
hasDatasetId: true,
credentialAvailable: false,
}),
).toBe("unverified")
})

test("dataset saved, scope missing, no manual token, credential available is missingPermission", () => {
expect(
getCapiStatus({
hasCapiScope: false,
hasManualCapiAccessToken: false,
hasDatasetId: true,
credentialAvailable: true,
}),
).toBe("missingPermission")
})

test("nothing configured with credential available is notConnected", () => {
expect(
getCapiStatus({
hasCapiScope: false,
hasManualCapiAccessToken: false,
hasDatasetId: false,
credentialAvailable: true,
}),
).toBe("notConnected")
})

test("user disconnect wins even with a dataset and scope", () => {
expect(
getCapiStatus({
hasCapiScope: true,
hasManualCapiAccessToken: false,
hasDatasetId: true,
credentialAvailable: true,
capiDisconnected: true,
}),
).toBe("notConnected")
})

test("user disconnect wins even with a manual token and dataset", () => {
expect(
getCapiStatus({
hasCapiScope: false,
hasManualCapiAccessToken: true,
hasDatasetId: true,
credentialAvailable: true,
capiDisconnected: true,
}),
).toBe("notConnected")
})

test("user disconnect with a saved dataset is notConnected, not missingPermission", () => {
expect(
getCapiStatus({
hasCapiScope: false,
hasManualCapiAccessToken: false,
hasDatasetId: true,
credentialAvailable: true,
capiDisconnected: true,
}),
).toBe("notConnected")
})
})
157 changes: 157 additions & 0 deletions apps/builder/__tests__/capi-test-event-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// @vitest-environment node

import { beforeEach, describe, expect, test, vi } from "vitest"
import { saveCapiTestEventCodeAction } from "../src/features/meta-conversions/actions/save-capi-test-event-code.action"
import { sendCapiTestEventAction } from "../src/features/meta-conversions/actions/send-capi-test-event.action"

type SaveHandler = (args: {
parsedInput: {
channel: "messenger" | "instagram" | "whatsapp"
testEventCode: string | null
}
bindArgsParsedInputs: readonly [string, string]
}) => Promise<unknown>

type SendHandler = (args: {
parsedInput: { channel: "messenger" | "instagram" | "whatsapp" }
bindArgsParsedInputs: readonly [string, string]
}) => Promise<unknown>

const mocks = vi.hoisted(() => ({
assertWorkspaceSuperAdmin: vi.fn(),
messengerFindByIdForWorkspace: vi.fn(),
instagramFindByIdForWorkspace: vi.fn(),
whatsappFindByIdForWorkspace: vi.fn(),
saveCapiTestEventCode: vi.fn(),
enqueueTestEvent: vi.fn(),
}))

vi.mock("@/lib/safe-action", () => {
const chain: Record<string, unknown> = {}
chain.bindArgsSchemas = () => chain
chain.inputSchema = () => chain
chain.action = (handler: SaveHandler | SendHandler) => handler
return { workspaceActionClient: chain }
})

vi.mock("@/lib/auth/assert-workspace-super-admin", () => ({
assertWorkspaceSuperAdmin: mocks.assertWorkspaceSuperAdmin,
}))

vi.mock("@chatbotx.io/business", () => {
class CapiTestEventError extends Error {
readonly reason: string

constructor(reason: string) {
super(reason)
this.name = "CapiTestEventError"
this.reason = reason
}
}
return {
CapiTestEventError,
messengerIntegrationService: {
findByIdForWorkspace: mocks.messengerFindByIdForWorkspace,
},
instagramIntegrationService: {
findByIdForWorkspace: mocks.instagramFindByIdForWorkspace,
},
integrationWhatsappService: {
findByIdForWorkspace: mocks.whatsappFindByIdForWorkspace,
},
metaConversionsService: {
saveCapiTestEventCode: mocks.saveCapiTestEventCode,
enqueueTestEvent: mocks.enqueueTestEvent,
},
}
})

vi.mock("next-intl/server", () => ({
getTranslations: async () => (key: string) => `t:${key}`,
}))

const save = saveCapiTestEventCodeAction as unknown as SaveHandler
const send = sendCapiTestEventAction as unknown as SendHandler
const bound = ["ws-1", "im-1"] as const
const integration = { id: "im-1", workspaceId: "ws-1" }

describe("CAPI test event actions", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.messengerFindByIdForWorkspace.mockResolvedValue(integration)
mocks.whatsappFindByIdForWorkspace.mockResolvedValue(integration)
})

test("save routes to the channel's integration lookup and stores the code", async () => {
await expect(
save({
parsedInput: { channel: "messenger", testEventCode: "TEST33520" },
bindArgsParsedInputs: bound,
}),
).resolves.toEqual({ success: true, testEventCode: "TEST33520" })

expect(mocks.assertWorkspaceSuperAdmin).toHaveBeenCalledWith("ws-1")
expect(mocks.messengerFindByIdForWorkspace).toHaveBeenCalledWith({
id: "im-1",
workspaceId: "ws-1",
})
expect(mocks.saveCapiTestEventCode).toHaveBeenCalledWith({
channel: "messenger",
integration,
testEventCode: "TEST33520",
})
})

test("save with null clears the code for a WhatsApp integration", async () => {
await save({
parsedInput: { channel: "whatsapp", testEventCode: null },
bindArgsParsedInputs: bound,
})

expect(mocks.whatsappFindByIdForWorkspace).toHaveBeenCalled()
expect(mocks.saveCapiTestEventCode).toHaveBeenCalledWith(
expect.objectContaining({ channel: "whatsapp", testEventCode: null }),
)
})

test("save surfaces a translated not-found error per channel", async () => {
mocks.instagramFindByIdForWorkspace.mockResolvedValue(null)

await expect(
save({
parsedInput: { channel: "instagram", testEventCode: "TEST1" },
bindArgsParsedInputs: bound,
}),
).rejects.toThrow("t:instagramNotFound")
expect(mocks.saveCapiTestEventCode).not.toHaveBeenCalled()
})

test("send queues a test event and reports whether a row was created", async () => {
mocks.enqueueTestEvent.mockResolvedValue({ id: "mce-1" })

await expect(
send({
parsedInput: { channel: "messenger" },
bindArgsParsedInputs: bound,
}),
).resolves.toEqual({ success: true, queued: true })
expect(mocks.enqueueTestEvent).toHaveBeenCalledWith({
channel: "messenger",
integration,
})
})

test("send translates a CapiTestEventError reason for the toast", async () => {
const { CapiTestEventError } = await import("@chatbotx.io/business")
mocks.enqueueTestEvent.mockRejectedValue(
new CapiTestEventError("noContactForTest"),
)

await expect(
send({
parsedInput: { channel: "messenger" },
bindArgsParsedInputs: bound,
}),
).rejects.toThrow("t:noContactForTest")
})
})
2 changes: 0 additions & 2 deletions apps/builder/__tests__/contact-inbox-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
// requests can settle out of order (the initial one is often slower — it
// races the Graph/Telegram round trip the refresh triggers) — whichever
// request was issued LAST must win, never whichever RESOLVES last.
// See docs/plans/2026-08-31-messenger-ctm-profile-backfill.md, fix wave 2
// finding 3.
// ---------------------------------------------------------------------------

vi.mock("next-intl", () => ({
Expand Down
55 changes: 55 additions & 0 deletions apps/builder/__tests__/event-label.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// @vitest-environment node
import { createTranslator } from "next-intl"
import { describe, expect, test } from "vitest"
import messages from "../messages/en.json"
import {
getMetaCapiActionSourceLabel,
getMetaCapiContentTypeLabel,
getMetaCapiEventLabel,
META_CAPI_ACTION_SOURCE_DOCS_URL,
} from "../src/features/meta-conversions/lib/event-label"

const t = createTranslator({ locale: "en", messages })

describe("getMetaCapiEventLabel", () => {
test("returns the translated label for a business-messaging standard event", () => {
expect(getMetaCapiEventLabel("LeadSubmitted", t)).toBe("Lead Submitted")
expect(getMetaCapiEventLabel("Purchase", t)).toBe("Purchase")
expect(getMetaCapiEventLabel("CartAbandoned", t)).toBe("Cart Abandoned")
})

test("returns the translated label for a pixel-only standard event", () => {
expect(getMetaCapiEventLabel("Lead", t)).toBe("Lead")
expect(getMetaCapiEventLabel("AddPaymentInfo", t)).toBe("Add Payment Info")
expect(getMetaCapiEventLabel("Subscribe", t)).toBe("Subscribe")
})

test("returns the raw custom name verbatim for an unknown event name", () => {
expect(getMetaCapiEventLabel("MyCustomEvent", t)).toBe("MyCustomEvent")
})
})

describe("getMetaCapiActionSourceLabel", () => {
test("returns the translated label for every action source", () => {
expect(getMetaCapiActionSourceLabel("business_messaging", t)).toBe(
"Business Messaging",
)
expect(getMetaCapiActionSourceLabel("email", t)).toBe("Email")
expect(getMetaCapiActionSourceLabel("other", t)).toBe("Other")
})
})

describe("getMetaCapiContentTypeLabel", () => {
test("returns the translated label for every content type", () => {
expect(getMetaCapiContentTypeLabel("product", t)).toBe("Product")
expect(getMetaCapiContentTypeLabel("product_group", t)).toBe(
"Product Group",
)
})
})

test("META_CAPI_ACTION_SOURCE_DOCS_URL points at Meta's action_source docs", () => {
expect(META_CAPI_ACTION_SOURCE_DOCS_URL).toBe(
"https://developers.facebook.com/documentation/ads-commerce/conversions-api/parameters/server-event#action_source",
)
})
Loading