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
90 changes: 90 additions & 0 deletions apps/builder/__tests__/google-sheets-disconnect-action.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// @vitest-environment node

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

const mocks = vi.hoisted(() => ({
auditRecord: vi.fn(),
disconnect: vi.fn(),
findByWorkspaceIdOrFail: vi.fn(),
loggerError: vi.fn(),
vendorDisconnect: vi.fn(),
}))

vi.mock("@/lib/safe-action", () => {
const chain: Record<string, unknown> = {}
chain.bindArgsSchemas = () => chain
chain.action = (fn: unknown) => fn
return {
authActionClient: chain,
}
})

vi.mock("@/lib/log", () => ({
logger: { error: mocks.loggerError },
}))

vi.mock("@chatbotx.io/business", () => ({
integrationGoogleSheetService: {
disconnect: mocks.disconnect,
findByWorkspaceIdOrFail: mocks.findByWorkspaceIdOrFail,
},
}))

vi.mock("@chatbotx.io/business/audit", () => ({
auditService: { record: mocks.auditRecord },
}))

vi.mock("@chatbotx.io/integration-google-sheets", () => ({
integration: { disconnect: mocks.vendorDisconnect },
}))

const { disconnectGoogleSheetsAction } = await import(
"../src/features/integration-google-sheets/actions/disconnect.action"
)

beforeEach(() => {
vi.clearAllMocks()
mocks.findByWorkspaceIdOrFail.mockResolvedValue({
integrationId: "integration-1",
auth: { accessToken: "token" },
})
mocks.disconnect.mockResolvedValue(undefined)
})

describe("disconnectGoogleSheetsAction", () => {
test("logs a failing vendor disconnect call but still runs the local disconnect", async () => {
mocks.vendorDisconnect.mockRejectedValue(new Error("vendor down"))

await (
disconnectGoogleSheetsAction as (props: unknown) => Promise<unknown>
)({ bindArgsParsedInputs: ["ws-1"] })

expect(mocks.loggerError).toHaveBeenCalledWith(
expect.any(Error),
"Unable to disconnect google sheets for workspace: ws-1",
)
expect(mocks.disconnect).toHaveBeenCalledWith({
workspaceId: "ws-1",
integrationId: "integration-1",
})
expect(mocks.auditRecord).toHaveBeenCalledWith({
workspaceId: "ws-1",
action: "disconnect",
detail: "disconnected the Google Sheets integration",
})
})

test("runs the local disconnect when the vendor call succeeds", async () => {
mocks.vendorDisconnect.mockResolvedValue(undefined)

await (
disconnectGoogleSheetsAction as (props: unknown) => Promise<unknown>
)({ bindArgsParsedInputs: ["ws-1"] })

expect(mocks.loggerError).not.toHaveBeenCalled()
expect(mocks.disconnect).toHaveBeenCalledWith({
workspaceId: "ws-1",
integrationId: "integration-1",
})
})
})
60 changes: 15 additions & 45 deletions apps/builder/__tests__/integration-tiktok-connect.action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,15 @@
import { beforeEach, describe, expect, test, vi } from "vitest"

const mocks = vi.hoisted(() => ({
connectChannelIntegration: vi.fn(),
connect: vi.fn(),
findWorkspaceById: vi.fn(),
transaction: vi.fn(),
insert: vi.fn(),
values: vi.fn(),
onConflictDoUpdate: vi.fn(),
returning: vi.fn(),
auditRecord: vi.fn(),
handleRequest: vi.fn(),
createId: vi.fn(() => "generated-integration-id"),
redirect: vi.fn(),
}))

vi.mock("@chatbotx.io/business", () => ({
connectChannelIntegration: mocks.connectChannelIntegration,
tiktokIntegrationService: { connect: mocks.connect },
workspaceService: { findById: mocks.findWorkspaceById },
}))

Expand All @@ -36,18 +30,6 @@ vi.mock("@chatbotx.io/business/errors", () => ({
},
}))

vi.mock("@chatbotx.io/database/client", () => ({
db: { transaction: mocks.transaction },
}))

vi.mock("@chatbotx.io/database/schema", () => ({
integrationTiktokModel: { id: "id", openId: "openId" },
}))

vi.mock("@chatbotx.io/utils", () => ({
createId: mocks.createId,
}))

vi.mock("next/navigation", () => ({
redirect: mocks.redirect,
}))
Expand All @@ -73,26 +55,13 @@ describe("connectTiktokHandler", () => {
username: "shop_1",
},
})
mocks.transaction.mockImplementation(async (fn: (tx: unknown) => unknown) =>
fn({
insert: mocks.insert,
}),
)
mocks.insert.mockReturnValue({ values: mocks.values })
mocks.values.mockReturnValue({
onConflictDoUpdate: mocks.onConflictDoUpdate,
})
mocks.onConflictDoUpdate.mockReturnValue({ returning: mocks.returning })
mocks.returning.mockResolvedValue([{ id: "existing-integration-id" }])
})

test("records reconnect audit with the persisted TikTok integration id on conflict", async () => {
mocks.connectChannelIntegration.mockImplementation(
async ({ insertIntegration }) => {
const integration = await insertIntegration("inbox-1", false)
return { wasCreated: false, integration }
},
)
mocks.connect.mockResolvedValue({
wasCreated: false,
integration: { id: "existing-integration-id" },
})

await connectTiktokHandler({
tiktokSettings: { clientId: "client", clientSecret: "secret" },
Expand All @@ -102,15 +71,16 @@ describe("connectTiktokHandler", () => {
redirectUrl: "https://app.example.com/integrations/tiktok/callback",
})

expect(mocks.values).toHaveBeenCalledWith(
expect.objectContaining({
id: "generated-integration-id",
inboxId: "inbox-1",
workspaceId: "workspace-1",
openId: "open-id-1",
expect(mocks.connect).toHaveBeenCalledWith({
workspaceId: "workspace-1",
ownerId: "owner-1",
openId: "open-id-1",
username: "shop_1",
displayName: "TikTok Shop",
auth: expect.objectContaining({
metadata: expect.objectContaining({ openId: "open-id-1" }),
}),
)
expect(mocks.returning).toHaveBeenCalledWith({ id: "id" })
})
expect(mocks.auditRecord).toHaveBeenCalledTimes(1)
expect(mocks.auditRecord).toHaveBeenCalledWith({
userId: "admin-1",
Expand Down
159 changes: 159 additions & 0 deletions apps/builder/__tests__/smtp-actions-thin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// @vitest-environment node

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

const mocks = vi.hoisted(() => ({
auditRecord: vi.fn(),
connect: vi.fn(),
findByIdForWorkspace: vi.fn(),
findWorkspace: vi.fn(),
isSameJsonValue: vi.fn(),
update: vi.fn(),
verifySmtpConnection: vi.fn(),
}))

const callOrder: string[] = []

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

vi.mock("@chatbotx.io/business", () => ({
integrationSmtpService: {
connect: mocks.connect,
findByIdForWorkspace: mocks.findByIdForWorkspace,
update: mocks.update,
},
workspaceService: { find: mocks.findWorkspace },
}))

vi.mock("@chatbotx.io/business/audit", () => ({
auditService: { record: mocks.auditRecord },
isSameJsonValue: mocks.isSameJsonValue,
}))

vi.mock("../src/features/integration-smtp/lib/verify-connection", () => ({
verifySmtpConnection: (...args: unknown[]) => {
callOrder.push("verify")
return Promise.resolve(mocks.verifySmtpConnection(...args))
},
}))

const { createSmtpAction } = await import(
"../src/features/integration-smtp/actions/create-smtp.action"
)
const { updateSmtpAction } = await import(
"../src/features/integration-smtp/actions/update-smtp.action"
)

beforeEach(() => {
vi.clearAllMocks()
callOrder.length = 0
mocks.findWorkspace.mockResolvedValue({ id: "ws-1", ownerId: "owner-1" })
mocks.connect.mockImplementation(() => {
callOrder.push("connect")
return Promise.resolve({ inbox: { id: "inbox-1" }, wasCreated: true })
})
mocks.findByIdForWorkspace.mockResolvedValue({
id: "smtp-1",
name: "old-name",
fromAddress: "old@example.com",
auth: {
authType: "custom",
provider: "google",
host: "smtp.gmail.com",
port: 587,
username: "old-user",
password: "old-pass",
},
})
mocks.update.mockResolvedValue({
id: "smtp-1",
name: "new-name",
fromAddress: "new@example.com",
})
})

describe("createSmtpAction", () => {
test("calls verifySmtpConnection before integrationSmtpService.connect", async () => {
await (createSmtpAction as (props: unknown) => Promise<unknown>)({
bindArgsParsedInputs: ["ws-1"],
parsedInput: {
provider: "google",
host: "ignored.example.com",
port: 25,
username: "user1",
password: "pass1",
fromAddress: "from@example.com",
},
})

expect(callOrder).toEqual(["verify", "connect"])
})

test("a non-other provider passes smtpHostMap-resolved host/port", async () => {
await (createSmtpAction as (props: unknown) => Promise<unknown>)({
bindArgsParsedInputs: ["ws-1"],
parsedInput: {
provider: "google",
host: "ignored.example.com",
port: 25,
username: "user1",
password: "pass1",
fromAddress: "from@example.com",
},
})

expect(mocks.connect).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
host: "smtp.gmail.com",
port: 587,
}),
}),
)
})
})

describe("updateSmtpAction", () => {
test("records an audit only when isSameJsonValue reports a change", async () => {
mocks.isSameJsonValue.mockReturnValue(false)

await (updateSmtpAction as (props: unknown) => Promise<unknown>)({
bindArgsParsedInputs: ["ws-1", "smtp-1"],
parsedInput: {
provider: "google",
host: "",
port: 0,
username: "new-user",
password: "new-pass",
fromAddress: "new@example.com",
},
})

expect(mocks.auditRecord).toHaveBeenCalledTimes(1)

mocks.auditRecord.mockClear()
mocks.isSameJsonValue.mockReturnValue(true)

await (updateSmtpAction as (props: unknown) => Promise<unknown>)({
bindArgsParsedInputs: ["ws-1", "smtp-1"],
parsedInput: {
provider: "google",
host: "",
port: 0,
username: "old-user",
password: "old-pass",
fromAddress: "old@example.com",
},
})

expect(mocks.auditRecord).not.toHaveBeenCalled()
})
})
Loading
Loading