Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,126 @@

exports[`public API spec — operation naming guard > operation list (operationId, method, path) matches the committed snapshot 1`] = `
[
{
"method": "GET",
"operationId": "ads.checkCampaignPrerequisites",
"path": "/v1/ads/campaigns/prerequisites",
},
{
"method": "POST",
"operationId": "ads.createCampaign",
"path": "/v1/ads/campaigns",
},
{
"method": "POST",
"operationId": "ads.createRule",
"path": "/v1/ads/conversion-rules",
},
{
"method": "DELETE",
"operationId": "ads.deleteCampaign",
"path": "/v1/ads/campaigns/{operationId}",
},
{
"method": "DELETE",
"operationId": "ads.deleteRule",
"path": "/v1/ads/conversion-rules/{id}",
},
{
"method": "GET",
"operationId": "ads.getCampaignAdAccountDetails",
"path": "/v1/ads/campaigns/ad-accounts/{adAccountId}",
},
{
"method": "POST",
"operationId": "ads.getCampaignsInsights",
"path": "/v1/ads/campaigns/insights",
},
{
"method": "GET",
"operationId": "ads.getCampaignVideoStatus",
"path": "/v1/ads/campaigns/videos/{videoId}/status",
},
{
"method": "GET",
"operationId": "ads.getCapiDelivery",
"path": "/v1/ads/capi-delivery",
},
{
"method": "GET",
"operationId": "ads.getFunnel",
"path": "/v1/ads/funnel",
},
{
"method": "GET",
"operationId": "ads.getFunnelTimeseries",
"path": "/v1/ads/funnel/timeseries",
},
{
"method": "GET",
"operationId": "ads.getRule",
"path": "/v1/ads/conversion-rules/{id}",
},
{
"method": "GET",
"operationId": "ads.listCampaignAdAccounts",
"path": "/v1/ads/campaigns/{channel}/{integrationId}/ad-accounts",
},
{
"method": "GET",
"operationId": "ads.listCampaignMessengerPages",
"path": "/v1/ads/campaigns/messenger-pages",
},
{
"method": "GET",
"operationId": "ads.listCampaigns",
"path": "/v1/ads/campaigns",
},
{
"method": "GET",
"operationId": "ads.listChannelAdAccounts",
"path": "/v1/ads/{channel}/ad-accounts",
},
{
"method": "GET",
"operationId": "ads.listConversionExportRows",
"path": "/v1/ads/conversions/export",
},
{
"method": "GET",
"operationId": "ads.listRules",
"path": "/v1/ads/conversion-rules",
},
{
"method": "POST",
"operationId": "ads.pauseCampaign",
"path": "/v1/ads/campaigns/{operationId}/pause",
},
{
"method": "POST",
"operationId": "ads.publishCampaign",
"path": "/v1/ads/campaigns/{operationId}/publish",
},
{
"method": "POST",
"operationId": "ads.retryCampaign",
"path": "/v1/ads/campaigns/{operationId}/retry",
},
{
"method": "PATCH",
"operationId": "ads.toggleRuleStatus",
"path": "/v1/ads/conversion-rules/{id}/status",
},
{
"method": "PUT",
"operationId": "ads.updateRule",
"path": "/v1/ads/conversion-rules/{id}",
},
{
"method": "POST",
"operationId": "ads.uploadCampaignVideo",
"path": "/v1/ads/campaigns/upload-video",
},
{
"method": "POST",
"operationId": "aiAgents.create",
Expand Down
215 changes: 215 additions & 0 deletions apps/builder/__tests__/ads-campaign-public-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// @vitest-environment node

import { beforeEach, describe, expect, test, vi } from "vitest"

vi.mock("@chatbotx.io/database/client", () => {
const proxy: unknown = new Proxy(() => proxy, { get: () => proxy })
return { db: proxy }
})

type RouteConfig = {
method: string
path: string
summary: string
tags: string[]
}

type CapturedProcedure = {
route: RouteConfig
handler?: (...args: any[]) => any
}

const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => {
const capturedProcedures: CapturedProcedure[] = []

const makeProcedure = (route: RouteConfig) => {
const record: CapturedProcedure = { route }
capturedProcedures.push(record)

const chain = {
input: vi.fn(() => chain),
output: vi.fn(() => chain),
errors: vi.fn(() => chain),
handler: vi.fn((fn: (...args: any[]) => any) => {
record.handler = fn
return { handler: fn }
}),
}
return chain
}

const workspaceTokenAuthAPI = {
route: vi.fn((config: RouteConfig) => makeProcedure(config)),
}

return {
workspaceTokenAuthAPIForScope: vi.fn(
(_scope: string) => workspaceTokenAuthAPI,
),
capturedProcedures,
}
})

vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope }))

const messagingAdCampaignService = {
createDraft: vi.fn(),
retryDraft: vi.fn(),
publish: vi.fn(),
pause: vi.fn(),
deleteOperation: vi.fn(),
list: vi.fn(),
listInsights: vi.fn(),
listMessengerPages: vi.fn(),
}

const messagingAdsConnectionService = {
findForIntegration: vi.fn(),
}

const listCachedMessagingAdAccounts = vi.fn()
const getCachedMessagingAdAccountDetails = vi.fn()
const buildMessagingAdsContext = vi.fn()

vi.mock("@chatbotx.io/business", () => ({
messagingAdCampaignService,
messagingAdsConnectionService,
listCachedMessagingAdAccounts,
getCachedMessagingAdAccountDetails,
buildMessagingAdsContext,
}))

vi.mock("@chatbotx.io/integration-facebook-ads", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@chatbotx.io/integration-facebook-ads")
>()
return {
...actual,
integration: { runAction: vi.fn() },
}
})

await import("@/features/ads-campaign/api/public")

const findProcedure = (method: string, path: string) => {
const found = capturedProcedures.find(
(p) => p.route.method === method && p.route.path === path,
)
if (!found) {
throw new Error(`No procedure registered for ${method} ${path}`)
}
return found
}

const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0]

const OPERATION_RECORD = {
id: "op-1",
workspaceId: "1001",
channel: "whatsapp",
adAccountId: "act_1",
name: "Ad",
createState: "created",
publishState: "draft",
metaCampaignId: null,
metaAdSetId: null,
metaAdCreativeId: null,
metaAdId: null,
lastError: null,
cleanupError: null,
createdAt: new Date(),
}

beforeEach(() => {
vi.clearAllMocks()
})

test("registers the ads-campaign public router under the ads scope", () => {
expect(scopeArgAtImport).toBe("ads")
})

describe("POST /v1/ads/campaigns", () => {
const procedure = findProcedure("POST", "/v1/ads/campaigns")

test("creates a campaign with no session user in context and no createdBy", async () => {
messagingAdCampaignService.createDraft.mockResolvedValueOnce(
OPERATION_RECORD,
)

// No `context.user` at all — the token auth stack never sets one. This
// must NOT throw `errors.superAdminRequired` (the private handler's
// `assertWorkspaceSuperAdmin` guard, deliberately omitted here).
const result = await procedure.handler?.({
context: { workspace: { id: "1001" } },
input: {
channel: "whatsapp",
integrationId: "1",
adAccountId: "act_1",
name: "Ad",
campaign: { specialAdCategories: ["NONE"] },
adSet: {
dailyBudgetMinorUnits: 1000,
targeting: { countries: ["US"] },
},
creative: {
media: { kind: "video", videoId: "v1" },
welcomeMessage: { type: "default" },
},
},
})

expect(messagingAdCampaignService.createDraft).toHaveBeenCalledWith(
expect.objectContaining({ workspaceId: "1001" }),
)
// createdBy was never set on the merged input -> undefined, never a
// session user's id.
expect(
messagingAdCampaignService.createDraft.mock.calls[0]?.[0].createdBy,
).toBeUndefined()
// The public resource never leaks workspaceId.
expect(result).not.toHaveProperty("workspaceId")
})
})

describe("POST /v1/ads/campaigns/{operationId}/publish", () => {
const procedure = findProcedure(
"POST",
"/v1/ads/campaigns/{operationId}/publish",
)

test("publishes with no session user in context", async () => {
messagingAdCampaignService.publish.mockResolvedValueOnce(OPERATION_RECORD)

const result = await procedure.handler?.({
context: { workspace: { id: "1001" } },
input: { operationId: "op-1" },
})

expect(messagingAdCampaignService.publish).toHaveBeenCalledWith({
operationId: "op-1",
workspaceId: "1001",
})
expect(result).not.toHaveProperty("workspaceId")
})
})

describe("GET /v1/ads/campaigns", () => {
const procedure = findProcedure("GET", "/v1/ads/campaigns")

test("sources workspaceId from context and strips it from every row", async () => {
messagingAdCampaignService.list.mockResolvedValueOnce([
{ ...OPERATION_RECORD, effectiveStatus: null },
])

const result = await procedure.handler?.({
context: { workspace: { id: "1001" } },
input: { channel: "whatsapp", integrationId: "1" },
})

expect(messagingAdCampaignService.list).toHaveBeenCalledWith(
expect.objectContaining({ workspaceId: "1001" }),
)
expect(result?.data[0]).not.toHaveProperty("workspaceId")
})
})
Loading