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
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,11 @@ exports[`public API spec — operation naming guard > operation list (operationI
"operationId": "contacts.upsert",
"path": "/v1/contacts/{identifier}/upsert",
},
{
"method": "POST",
"operationId": "contactScans.cancel",
"path": "/v1/contact-scans/cancel",
},
{
"method": "POST",
"operationId": "contactScans.create",
Expand Down Expand Up @@ -2387,6 +2392,11 @@ exports[`public API spec — operation naming guard > operation list (operationI
"operationId": "messages.requestWhatsappCallPermission",
"path": "/v1/conversations/{conversationId}/whatsapp-call-permission",
},
{
"method": "POST",
"operationId": "messages.resend",
"path": "/v1/conversations/{conversationId}/messages/{messageId}/resend",
},
{
"method": "POST",
"operationId": "messages.sendWhatsappTemplate",
Expand Down
2 changes: 2 additions & 0 deletions apps/builder/__tests__/api-parity-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"contact-notes/create-contact-note.action:createContactNoteAction": "covered:contact-notes",
"contact-notes/delete-contact-note.action:deleteContactNoteAction": "covered:contact-notes",
"contact-notes/edit-contact-note.action:editContactNoteAction": "covered:contact-notes",
"contact-scan/cancel-contact-scan.action:cancelContactScanAction": "covered:contact-scan",
"contact-scan/schedule-contact-scan.action:scheduleContactScanAction": "covered:contact-scan",
"contact-sequences/update-contact-sequence.action:updateContactSequenceAction": "covered:contact-sequences",
"contacts/add-contact-custom-field.action:addContactCustomFieldAction": "covered:contacts",
Expand Down Expand Up @@ -349,6 +350,7 @@
"messages/delete-message.action:deleteMessageAction": "covered:messages",
"messages/edit-message.action:editMessage": "covered:messages",
"messages/edit-message.action:editMessageAction": "covered:messages",
"messages/resend-message.action:resendMessageAction": "covered:messages",
"messages/generate-call-ai-summary.action:generateCallAiSummaryAction": "covered:messages",
"messages/get-call-recording-url.action:getCallRecordingUrlAction": "covered:messages",
"messages/get-call-summary.action:getCallSummaryAction": "covered:messages",
Expand Down
16 changes: 16 additions & 0 deletions apps/builder/__tests__/authorize-workspace-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,22 @@ describe("assertWorkspaceOwnerAccessForMethod", () => {
).toBe(false)
})

test("allows cancelling a contact scan on a trial-expired workspace (UI parity), never for a read_only token", async () => {
isCloud.mockReturnValue(true)
getAccessState.mockResolvedValue({ blocked: true, reason: "status" })

await expect(
assertWorkspaceOwnerAccessForMethod({
method: "POST",
ownerId: "owner-1",
path: "/v1/contact-scans/cancel",
}),
).resolves.toBeUndefined()
expect(
isReadOnlyTokenAllowedMethod("POST", "/v1/contact-scans/cancel"),
).toBe(false)
})

test("blocks a different POST path for a trial-expired workspace", async () => {
isCloud.mockReturnValue(true)
getAccessState.mockResolvedValue({ blocked: true, reason: "status" })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,50 @@ describe("publicBroadcastResource send limit fields", () => {
expect(result.success && result.data.targets?.[0]?.inboxId).toBe("5")
})
})

describe("publicBroadcastResource status reason", () => {
const base = {
id: "1",
name: "Broadcast",
status: "cancelled",
schedulesType: "now",
schedulesAt: new Date(),
flowId: null,
contactCount: 5,
audienceRangeStart: null,
audienceRangeEnd: null,
sendRatePerMinute: null,
}

test("exposes the three status reason fields", () => {
expect(Object.keys(publicBroadcastResource.shape)).toEqual(
expect.arrayContaining([
"statusReason",
"statusErrorMessage",
"statusInboxId",
]),
)
})

test("parses a system-stopped broadcast and keeps the values", () => {
const result = publicBroadcastResource.parse({
...base,
statusReason: "accountSendRestricted",
statusErrorMessage: "restricted",
statusInboxId: "9",
})
expect(result.statusReason).toBe("accountSendRestricted")
expect(result.statusErrorMessage).toBe("restricted")
expect(result.statusInboxId).toBe("9")
})

test("a customer-stopped broadcast has null reason fields", () => {
const result = publicBroadcastResource.parse({
...base,
statusReason: null,
statusErrorMessage: null,
statusInboxId: null,
})
expect(result.statusReason).toBeNull()
})
})
69 changes: 69 additions & 0 deletions apps/builder/__tests__/broadcast-status-reason.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { act } from "react"
import { createRoot, type Root } from "react-dom/client"
import { afterEach, describe, expect, test, vi } from "vitest"
import { BroadcastStatusReason } from "@/features/broadcasts/components/broadcast-status-reason"

/** Echoes the key (and params) back so assertions never depend on English copy. */
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, values?: { name?: string }) =>
values?.name ? `${key}:${values.name}` : key,
}))

let container: HTMLDivElement | null = null
let root: Root | null = null

const render = (
broadcast: Parameters<typeof BroadcastStatusReason>[0]["broadcast"],
) => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
container = document.createElement("div")
document.body.append(container)
root = createRoot(container)
act(() => {
root?.render(<BroadcastStatusReason broadcast={broadcast} />)
})
return container
}

afterEach(() => {
act(() => root?.unmount())
container?.remove()
container = null
root = null
})

describe("BroadcastStatusReason", () => {
test("renders nothing without a status reason", () => {
const el = render({
statusReason: null,
statusErrorMessage: null,
statusInbox: null,
})
expect(el.textContent).toBe("")
})

test("renders the reason, the page and the provider error as a tooltip", () => {
const el = render({
statusReason: "accountPolicyBlocked",
statusErrorMessage: "(#2022) blocked",
statusInbox: { id: "1", name: "Spring page" },
})
expect(el.textContent).toContain(
"broadcasts.statusReason.accountPolicyBlocked",
)
expect(el.textContent).toContain("broadcasts.statusReason.page:Spring page")
expect(el.querySelector("p")?.getAttribute("title")).toBe("(#2022) blocked")
})

test("drops the page line when the inbox was deleted", () => {
const el = render({
statusReason: "accountSendRestricted",
statusErrorMessage: null,
statusInbox: null,
})
expect(el.textContent).toContain(
"broadcasts.statusReason.accountSendRestricted",
)
expect(el.textContent).not.toContain("broadcasts.statusReason.page")
})
})
89 changes: 89 additions & 0 deletions apps/builder/__tests__/cancel-contact-scan-action.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest"

const mockCancel = vi.fn()
const mockRequireUnrestrictedContactsScope = vi.fn()
const clientUsed = vi.hoisted(() => ({ name: "" }))

vi.mock("@chatbotx.io/business", () => ({
contactScanService: { cancel: (...args: unknown[]) => mockCancel(...args) },
}))

vi.mock("@/features/common/schema", () => ({ workspaceIdrequestParams: [] }))

// Cancel must keep working after the trial expires (invariant 14), so it must
// be built on the allow-expired client and never on the plain one.
vi.mock("@/lib/safe-action", () => {
const chain = (name: string) => ({
bindArgsSchemas: () => ({
inputSchema: () => ({
action: (fn: unknown) => {
clientUsed.name = name
return fn
},
}),
}),
})
return {
workspaceActionClientAllowExpired: chain("allowExpired"),
workspaceActionClient: chain("plain"),
}
})

vi.mock(
"@/features/contact-scan/lib/require-unrestricted-contacts-scope",
() => ({
requireUnrestrictedContactsScope: (...args: unknown[]) =>
mockRequireUnrestrictedContactsScope(...args),
}),
)

const { cancelContactScanAction: untyped } = await import(
"@/features/contact-scan/actions/cancel-contact-scan.action"
)
const cancelContactScanAction = untyped as unknown as (props: {
bindArgsParsedInputs: [string]
parsedInput: { inboxId: string }
}) => Promise<unknown>

const run = () =>
cancelContactScanAction({
bindArgsParsedInputs: ["ws-1"],
parsedInput: { inboxId: "inbox-1" },
})

describe("cancelContactScanAction", () => {
beforeEach(() => {
vi.clearAllMocks()
mockRequireUnrestrictedContactsScope.mockResolvedValue(undefined)
mockCancel.mockResolvedValue({ cancelled: true })
})

test("is built on the allow-expired client", () => {
expect(clientUsed.name).toBe("allowExpired")
})

test("checks the contacts scope, then cancels the inbox's scan", async () => {
await expect(run()).resolves.toEqual({ cancelled: true })

expect(mockRequireUnrestrictedContactsScope).toHaveBeenCalledWith("ws-1")
expect(mockCancel).toHaveBeenCalledWith({
workspaceId: "ws-1",
inboxId: "inbox-1",
})
})

test("an assigned-only member is denied and nothing is cancelled", async () => {
mockRequireUnrestrictedContactsScope.mockRejectedValue(
new Error("contactScanForbidden"),
)

await expect(run()).rejects.toThrow("contactScanForbidden")
expect(mockCancel).not.toHaveBeenCalled()
})

test("passes cancelled:false through when nothing is running", async () => {
mockCancel.mockResolvedValue({ cancelled: false })
await expect(run()).resolves.toEqual({ cancelled: false })
})
})
Loading
Loading