feat(api): add webhook CRUD API router and tests (#393)
* feat(api): add webhook CRUD API router and tests Add tRPC router for managing workspace webhooks: - list, create, update, delete endpoints (admin role required) - test endpoint to send a synthetic payload to a webhook URL - URL validation, event subscription filtering - Unit tests for all router procedures - Integration tests with PGlite test database - Add vitest config and test infrastructure for API package Depends on #391 (DB schema & repository). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(api): use assertPermission instead of assertUserInWorkspace Replace assertUserInWorkspace with assertPermission("workspace:manage") per project conventions. The permissions system is the preferred authorization approach for new code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(api): use @kan/db alias instead of relative imports in tests Replace relative path imports (../../db/src/...) with the @kan/db alias configured in vitest.config.ts for consistency and robustness. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(api): use webhookUrlSchema in router input validation Cherry-pick router-related changes from b2cc9ac: - Use extracted webhookUrlSchema zod validator in create/update input schemas for consistent SSRF checks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(api): replace dynamic import with static import for webhook utility Add packages/api/src/utils/webhook.ts with sendWebhookToUrl, createCardWebhookPayload, and webhookUrlSchema. Replace the dynamic import() in the test endpoint with a static import at the top of the file for better tree-shaking, type-checking, and readability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(api): align sendWebhooksForWorkspace tests with merged PR #392 The merged delivery utility uses client-side event filtering (getActiveByWorkspaceId takes 2 args, not 3). Update test assertions to match the actual implementation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Henry <30578846+hjball@users.noreply.github.com>
This commit is contained in:
75
packages/api/integration-tests/test-db.ts
Normal file
75
packages/api/integration-tests/test-db.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
|
||||
import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm";
|
||||
import { drizzle } from "drizzle-orm/pglite";
|
||||
import { migrate } from "drizzle-orm/pglite/migrator";
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
import * as schema from "@kan/db/schema";
|
||||
|
||||
export type TestDbClient = NodePgDatabase<typeof schema> & {
|
||||
$client: Pool;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a fresh in-memory PGlite database for testing.
|
||||
* Each call returns an isolated database instance with migrations applied.
|
||||
*/
|
||||
export async function createTestDb(): Promise<TestDbClient> {
|
||||
const client = new PGlite({
|
||||
extensions: { uuid_ossp, pg_trgm },
|
||||
});
|
||||
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
// Run migrations
|
||||
await migrate(db, { migrationsFolder: "../../packages/db/migrations" });
|
||||
|
||||
return db as unknown as TestDbClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a test database with a workspace and user for testing.
|
||||
* Returns the created entities for use in tests.
|
||||
*/
|
||||
export async function seedTestData(db: TestDbClient) {
|
||||
// Create a test user
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create a test workspace (publicId must be exactly 12 chars)
|
||||
const [workspace] = await db
|
||||
.insert(schema.workspaces)
|
||||
.values({
|
||||
publicId: "wstest123456",
|
||||
name: "Test Workspace",
|
||||
slug: "test-workspace",
|
||||
ownerId: user!.id,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Add user as admin member of workspace
|
||||
await db.insert(schema.workspaceMembers).values({
|
||||
publicId: "wm1234567890",
|
||||
email: user!.email,
|
||||
workspaceId: workspace!.id,
|
||||
userId: user!.id,
|
||||
createdBy: user!.id,
|
||||
role: "admin",
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
return { user: user!, workspace: workspace! };
|
||||
}
|
||||
242
packages/api/integration-tests/webhook.integration.test.ts
Normal file
242
packages/api/integration-tests/webhook.integration.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import { createTestDb, seedTestData, type TestDbClient } from "./test-db";
|
||||
|
||||
describe("webhook repository integration tests", () => {
|
||||
let db: TestDbClient;
|
||||
let testUser: { id: string; name: string | null };
|
||||
let testWorkspace: { id: number; publicId: string };
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await createTestDb();
|
||||
const seeded = await seedTestData(db);
|
||||
testUser = seeded.user;
|
||||
testWorkspace = seeded.workspace;
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("creates a webhook with all fields", async () => {
|
||||
const webhook = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "My Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
secret: "my-secret",
|
||||
events: ["card.created", "card.updated"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
expect(webhook).not.toBeNull();
|
||||
expect(webhook!.name).toBe("My Webhook");
|
||||
expect(webhook!.url).toBe("https://example.com/webhook");
|
||||
expect(webhook!.events).toEqual(["card.created", "card.updated"]);
|
||||
expect(webhook!.active).toBe(true);
|
||||
expect(webhook!.publicId).toMatch(/^[a-zA-Z0-9]{12}$/);
|
||||
});
|
||||
|
||||
it("creates a webhook without secret", async () => {
|
||||
const webhook = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "No Secret Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.deleted"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
expect(webhook).not.toBeNull();
|
||||
expect(webhook!.name).toBe("No Secret Webhook");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getByPublicId", () => {
|
||||
it("retrieves a webhook by public ID", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved!.publicId).toBe(created!.publicId);
|
||||
expect(retrieved!.name).toBe("Test Webhook");
|
||||
});
|
||||
|
||||
it("returns null for non-existent public ID", async () => {
|
||||
const retrieved = await webhookRepo.getByPublicId(db, "nonexistent12");
|
||||
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllByWorkspaceId", () => {
|
||||
it("returns all webhooks for a workspace", async () => {
|
||||
await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Webhook 1",
|
||||
url: "https://example.com/webhook1",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Webhook 2",
|
||||
url: "https://example.com/webhook2",
|
||||
events: ["card.updated"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
|
||||
|
||||
expect(webhooks).toHaveLength(2);
|
||||
expect(webhooks.map((w) => w.name)).toContain("Webhook 1");
|
||||
expect(webhooks.map((w) => w.name)).toContain("Webhook 2");
|
||||
});
|
||||
|
||||
it("returns empty array for workspace with no webhooks", async () => {
|
||||
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
|
||||
|
||||
expect(webhooks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveByWorkspaceId", () => {
|
||||
it("returns only active webhooks", async () => {
|
||||
const active = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Active Webhook",
|
||||
url: "https://example.com/active",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const inactive = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Inactive Webhook",
|
||||
url: "https://example.com/inactive",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
// Deactivate one webhook
|
||||
await webhookRepo.update(db, inactive!.publicId, { active: false });
|
||||
|
||||
const activeWebhooks = await webhookRepo.getActiveByWorkspaceId(db, testWorkspace.id);
|
||||
|
||||
expect(activeWebhooks).toHaveLength(1);
|
||||
// getActiveByWorkspaceId returns only publicId, url, secret, events
|
||||
expect(activeWebhooks[0]!.url).toBe("https://example.com/active");
|
||||
expect(activeWebhooks[0]!.publicId).toBe(active!.publicId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("updates webhook name", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Original Name",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.name).toBe("Updated Name");
|
||||
expect(updated!.url).toBe("https://example.com/webhook"); // Unchanged
|
||||
});
|
||||
|
||||
it("updates webhook events", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
events: ["card.created", "card.updated", "card.deleted"],
|
||||
});
|
||||
|
||||
expect(updated!.events).toEqual(["card.created", "card.updated", "card.deleted"]);
|
||||
});
|
||||
|
||||
it("updates webhook active status", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
expect(created!.active).toBe(true);
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
active: false,
|
||||
});
|
||||
|
||||
expect(updated!.active).toBe(false);
|
||||
});
|
||||
|
||||
it("sets updatedAt timestamp on update", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
// create() doesn't return updatedAt, verify via getByPublicId
|
||||
const initial = await webhookRepo.getByPublicId(db, created!.publicId);
|
||||
expect(initial!.updatedAt).toBeNull();
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
name: "Updated",
|
||||
});
|
||||
|
||||
expect(updated!.updatedAt).not.toBeNull();
|
||||
expect(updated!.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns null for non-existent webhook", async () => {
|
||||
const updated = await webhookRepo.update(db, "nonexistent12", {
|
||||
name: "Updated",
|
||||
});
|
||||
|
||||
expect(updated).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hardDelete", () => {
|
||||
it("deletes a webhook permanently", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "To Be Deleted",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
await webhookRepo.hardDelete(db, created!.publicId);
|
||||
|
||||
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it("does not throw for non-existent webhook", async () => {
|
||||
await expect(
|
||||
webhookRepo.hardDelete(db, "nonexistent12"),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,8 @@
|
||||
"dev": "tsc",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -60,7 +62,8 @@
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { listRouter } from "./routers/list";
|
||||
import { memberRouter } from "./routers/member";
|
||||
import { permissionRouter } from "./routers/permission";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { webhookRouter } from "./routers/webhook";
|
||||
import { workspaceRouter } from "./routers/workspace";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
||||
import: importRouter,
|
||||
permission: permissionRouter,
|
||||
user: userRouter,
|
||||
webhook: webhookRouter,
|
||||
workspace: workspaceRouter,
|
||||
integration: integrationRouter,
|
||||
});
|
||||
|
||||
423
packages/api/src/routers/webhook.test.ts
Normal file
423
packages/api/src/routers/webhook.test.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
vi.mock("@kan/db/repository/webhook.repo", () => ({
|
||||
getAllByWorkspaceId: vi.fn(),
|
||||
getByPublicId: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
hardDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/workspace.repo", () => ({
|
||||
getByPublicId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/permissions", () => ({
|
||||
assertPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
|
||||
const mockGetAllByWorkspaceId = webhookRepo.getAllByWorkspaceId as ReturnType<typeof vi.fn>;
|
||||
const mockGetByPublicId = webhookRepo.getByPublicId as ReturnType<typeof vi.fn>;
|
||||
const mockCreate = webhookRepo.create as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = webhookRepo.update as ReturnType<typeof vi.fn>;
|
||||
const mockHardDelete = webhookRepo.hardDelete as ReturnType<typeof vi.fn>;
|
||||
const mockWorkspaceGetByPublicId = workspaceRepo.getByPublicId as ReturnType<typeof vi.fn>;
|
||||
const mockAssertPermission = assertPermission as ReturnType<typeof vi.fn>;
|
||||
|
||||
// We need to import the router after mocks are set up
|
||||
// Testing approach: call the internal handler logic through a test wrapper
|
||||
describe("webhook router", () => {
|
||||
const mockDb = {} as never;
|
||||
const mockUser = { id: "user-123", name: "Test User", email: "test@example.com" };
|
||||
const mockWorkspace = { id: 1, publicId: "ws-123456789" };
|
||||
const mockWebhook = {
|
||||
id: 1,
|
||||
publicId: "wh-123456789",
|
||||
workspaceId: 1,
|
||||
name: "My Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
secret: "secret123",
|
||||
events: ["card.created", "card.updated"] as const,
|
||||
active: true,
|
||||
createdAt: new Date("2024-01-15"),
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAssertPermission.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("authorization", () => {
|
||||
it("throws UNAUTHORIZED when user is not authenticated", async () => {
|
||||
// Import fresh to get mocked version
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const ctx = {
|
||||
user: null,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when workspace does not exist", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-nonexistent" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("checks workspace:manage permission via assertPermission", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetAllByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" });
|
||||
|
||||
expect(mockAssertPermission).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
mockUser.id,
|
||||
mockWorkspace.id,
|
||||
"workspace:manage",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list", () => {
|
||||
it("returns all webhooks for workspace", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetAllByWorkspaceId.mockResolvedValueOnce([mockWebhook]);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).list({
|
||||
workspacePublicId: "ws-123456789",
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.name).toBe("My Webhook");
|
||||
expect(mockGetAllByWorkspaceId).toHaveBeenCalledWith(mockDb, mockWorkspace.id);
|
||||
});
|
||||
|
||||
it("returns empty array when no webhooks exist", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetAllByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).list({
|
||||
workspacePublicId: "ws-123456789",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("creates a webhook with valid input", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const newWebhook = {
|
||||
publicId: "wh-new123456",
|
||||
name: "New Webhook",
|
||||
url: "https://example.com/new",
|
||||
events: ["card.created"] as const,
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockCreate.mockResolvedValueOnce(newWebhook);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).create({
|
||||
workspacePublicId: "ws-123456789",
|
||||
name: "New Webhook",
|
||||
url: "https://example.com/new",
|
||||
events: ["card.created"],
|
||||
});
|
||||
|
||||
expect(result.name).toBe("New Webhook");
|
||||
expect(mockCreate).toHaveBeenCalledWith(mockDb, {
|
||||
workspaceId: mockWorkspace.id,
|
||||
name: "New Webhook",
|
||||
url: "https://example.com/new",
|
||||
secret: undefined,
|
||||
events: ["card.created"],
|
||||
createdBy: mockUser.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a webhook with secret", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const newWebhook = {
|
||||
publicId: "wh-new123456",
|
||||
name: "Secure Webhook",
|
||||
url: "https://example.com/secure",
|
||||
events: ["card.created"] as const,
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockCreate.mockResolvedValueOnce(newWebhook);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await webhookRouter.createCaller(ctx).create({
|
||||
workspacePublicId: "ws-123456789",
|
||||
name: "Secure Webhook",
|
||||
url: "https://example.com/secure",
|
||||
secret: "my-secret-key",
|
||||
events: ["card.created"],
|
||||
});
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(mockDb, expect.objectContaining({
|
||||
secret: "my-secret-key",
|
||||
}));
|
||||
});
|
||||
|
||||
it("throws INTERNAL_SERVER_ERROR when create fails", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockCreate.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).create({
|
||||
workspacePublicId: "ws-123456789",
|
||||
name: "New Webhook",
|
||||
url: "https://example.com/new",
|
||||
events: ["card.created"],
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("updates webhook name", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const updatedWebhook = { ...mockWebhook, name: "Updated Name" };
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
|
||||
mockUpdate.mockResolvedValueOnce(updatedWebhook);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).update({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
expect(result.name).toBe("Updated Name");
|
||||
expect(mockUpdate).toHaveBeenCalledWith(mockDb, "wh-123456789", {
|
||||
name: "Updated Name",
|
||||
url: undefined,
|
||||
secret: undefined,
|
||||
events: undefined,
|
||||
active: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when webhook does not exist", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).update({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-nonexistent",
|
||||
name: "Updated Name",
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when webhook belongs to different workspace", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const webhookFromDifferentWorkspace = { ...mockWebhook, workspaceId: 999 };
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(webhookFromDifferentWorkspace);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).update({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
name: "Updated Name",
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("deletes webhook successfully", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
|
||||
mockHardDelete.mockResolvedValueOnce(undefined);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).delete({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockHardDelete).toHaveBeenCalledWith(mockDb, "wh-123456789");
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when webhook does not exist", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).delete({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-nonexistent",
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("test", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("sends test payload to webhook URL", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).test({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
mockWebhook.url,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Event": "card.created",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns error when test fails", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).test({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.statusCode).toBe(500);
|
||||
expect(result.error).toContain("500");
|
||||
});
|
||||
});
|
||||
});
|
||||
359
packages/api/src/routers/webhook.ts
Normal file
359
packages/api/src/routers/webhook.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { webhookEvents } from "@kan/db/schema";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import {
|
||||
webhookUrlSchema,
|
||||
sendWebhookToUrl,
|
||||
createCardWebhookPayload,
|
||||
} from "../utils/webhook";
|
||||
|
||||
const webhookEventSchema = z.enum(webhookEvents);
|
||||
|
||||
export const webhookRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get all webhooks for a workspace",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks",
|
||||
description: "Retrieves all webhooks configured for a workspace",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
events: z.array(webhookEventSchema),
|
||||
active: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date().nullable(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
return webhookRepo.getAllByWorkspaceId(ctx.db, workspace.id);
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Create a webhook",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks",
|
||||
description: "Creates a new webhook for a workspace",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
name: z.string().min(1).max(255),
|
||||
url: webhookUrlSchema,
|
||||
secret: z.string().max(512).optional(),
|
||||
events: z.array(webhookEventSchema).min(1),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
events: z.array(webhookEventSchema),
|
||||
active: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
const result = await webhookRepo.create(ctx.db, {
|
||||
workspaceId: workspace.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
secret: input.secret,
|
||||
events: input.events,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: "Unable to create webhook",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a webhook",
|
||||
method: "PUT",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}",
|
||||
description: "Updates a webhook by its public ID",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
webhookPublicId: z.string().min(12),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
url: webhookUrlSchema.optional(),
|
||||
secret: z.string().max(512).optional(),
|
||||
events: z.array(webhookEventSchema).min(1).optional(),
|
||||
active: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
events: z.array(webhookEventSchema),
|
||||
active: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date().nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
const webhook = await webhookRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.webhookPublicId,
|
||||
);
|
||||
|
||||
if (!webhook || webhook.workspaceId !== workspace.id)
|
||||
throw new TRPCError({
|
||||
message: "Webhook not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const result = await webhookRepo.update(ctx.db, input.webhookPublicId, {
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
secret: input.secret,
|
||||
events: input.events,
|
||||
active: input.active,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: "Unable to update webhook",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a webhook",
|
||||
method: "DELETE",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}",
|
||||
description: "Deletes a webhook by its public ID",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
webhookPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
const webhook = await webhookRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.webhookPublicId,
|
||||
);
|
||||
|
||||
if (!webhook || webhook.workspaceId !== workspace.id)
|
||||
throw new TRPCError({
|
||||
message: "Webhook not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await webhookRepo.hardDelete(ctx.db, input.webhookPublicId);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
test: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Test a webhook",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}/test",
|
||||
description: "Sends a test payload to a webhook",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
webhookPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
success: z.boolean(),
|
||||
statusCode: z.number().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
const webhook = await webhookRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.webhookPublicId,
|
||||
);
|
||||
|
||||
if (!webhook || webhook.workspaceId !== workspace.id)
|
||||
throw new TRPCError({
|
||||
message: "Webhook not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const testPayload = createCardWebhookPayload("card.created", {
|
||||
id: "test-card-id",
|
||||
title: "Test Card",
|
||||
description: "This is a test webhook payload",
|
||||
dueDate: null,
|
||||
listId: "test-list-id",
|
||||
}, {
|
||||
boardId: "test-board-id",
|
||||
boardName: "Test Board",
|
||||
listName: "Test List",
|
||||
user: {
|
||||
id: userId,
|
||||
name: ctx.user?.name ?? "Test User",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sendWebhookToUrl(
|
||||
webhook.url,
|
||||
webhook.secret ?? undefined,
|
||||
testPayload,
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
@@ -404,11 +404,10 @@ describe("webhook utilities", () => {
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
// Event filtering now happens at DB level
|
||||
// getActiveByWorkspaceId fetches all active webhooks; event filtering is client-side
|
||||
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
1,
|
||||
"card.created",
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
@@ -421,8 +420,8 @@ describe("webhook utilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send when no webhooks match the event (DB-level filtering)", async () => {
|
||||
// DB-level event filter returns empty array when no webhooks match
|
||||
it("does not send when no webhooks match the event (client-side filtering)", async () => {
|
||||
// Returns webhooks that don't match the event — client-side filter excludes them
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
@@ -430,7 +429,6 @@ describe("webhook utilities", () => {
|
||||
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
1,
|
||||
"card.created",
|
||||
);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -185,12 +185,13 @@ export async function sendWebhooksForWorkspace(
|
||||
payload: WebhookPayload,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get active webhooks for this workspace
|
||||
const webhooks = await webhookRepo.getActiveByWorkspaceId(db, workspaceId);
|
||||
|
||||
// Filter webhooks that are subscribed to this specific event
|
||||
const webhooksForEvent = webhooks.filter((webhook) =>
|
||||
webhook.events.includes(payload.event),
|
||||
// Get active webhooks for this workspace and filter by event client-side
|
||||
const allWebhooks = await webhookRepo.getActiveByWorkspaceId(
|
||||
db,
|
||||
workspaceId,
|
||||
);
|
||||
const webhooks = allWebhooks.filter((w) =>
|
||||
w.events.includes(payload.event),
|
||||
);
|
||||
|
||||
// Send to all subscribed webhooks in parallel (fire and forget)
|
||||
|
||||
13
packages/api/vitest.config.ts
Normal file
13
packages/api/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts", "integration-tests/**/*.test.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@kan/db": resolve(__dirname, "../db/src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
89
pnpm-lock.yaml
generated
89
pnpm-lock.yaml
generated
@@ -351,6 +351,9 @@ importers:
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.2
|
||||
vitest:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1)
|
||||
|
||||
packages/auth:
|
||||
dependencies:
|
||||
@@ -12681,6 +12684,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1)
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
tinyrainbow: 2.0.0
|
||||
@@ -17944,6 +17955,27 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite-node@3.2.4(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite@7.3.1(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
@@ -17959,6 +17991,21 @@ snapshots:
|
||||
terser: 5.44.1
|
||||
yaml: 2.8.1
|
||||
|
||||
vite@7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.56.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 25.0.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.4.2
|
||||
terser: 5.44.1
|
||||
yaml: 2.8.1
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
@@ -18001,6 +18048,48 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
'@vitest/spy': 3.2.4
|
||||
'@vitest/utils': 3.2.4
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.15
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1)
|
||||
vite-node: 3.2.4(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 25.0.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
watchpack@2.4.4:
|
||||
|
||||
Reference in New Issue
Block a user