kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ci): add api project integration harness

Tin 8559555e 9665488a

+343 -5
+29
tests/api-integration/helpers/auth.ts
··· 1 + import type { Session, User } from "better-auth/types"; 2 + import { vi } from "vitest"; 3 + import { auth } from "../../../apps/api/src/auth"; 4 + 5 + function createSession(userId: string): Session { 6 + const now = new Date(); 7 + 8 + return { 9 + id: `session-${userId}`, 10 + token: `token-${userId}`, 11 + userId, 12 + expiresAt: new Date(now.getTime() + 60 * 60 * 1000), 13 + createdAt: now, 14 + updatedAt: now, 15 + ipAddress: null, 16 + userAgent: null, 17 + }; 18 + } 19 + 20 + export function mockAuthenticatedSession(user: User) { 21 + return vi.spyOn(auth.api, "getSession").mockResolvedValue({ 22 + session: createSession(user.id), 23 + user, 24 + }); 25 + } 26 + 27 + export function mockAnonymousSession() { 28 + return vi.spyOn(auth.api, "getSession").mockResolvedValue(null); 29 + }
+108
tests/api-integration/helpers/database.ts
··· 1 + import { dirname, resolve } from "node:path"; 2 + import { fileURLToPath } from "node:url"; 3 + import { sql } from "drizzle-orm"; 4 + import { migrate } from "drizzle-orm/node-postgres/migrator"; 5 + import { Client } from "pg"; 6 + import db from "../../../apps/api/src/database"; 7 + 8 + const currentDir = dirname(fileURLToPath(import.meta.url)); 9 + const migrationsFolder = resolve(currentDir, "../../../apps/api/drizzle"); 10 + 11 + let migrationPromise: Promise<void> | null = null; 12 + 13 + function getDatabaseName(connectionString: string) { 14 + return new URL(connectionString).pathname.replace(/^\//, ""); 15 + } 16 + 17 + function getAdminDatabaseUrl(connectionString: string) { 18 + const url = new URL(connectionString); 19 + const databaseName = getDatabaseName(connectionString); 20 + 21 + if (databaseName.endsWith("_test")) { 22 + url.pathname = `/${databaseName.slice(0, -5) || "postgres"}`; 23 + } 24 + 25 + return url.toString(); 26 + } 27 + 28 + function quoteIdentifier(identifier: string) { 29 + return `"${identifier.replaceAll('"', '""')}"`; 30 + } 31 + 32 + async function ensureTestDatabaseExists() { 33 + const connectionString = process.env.DATABASE_URL; 34 + 35 + if (!connectionString) { 36 + throw new Error("DATABASE_URL must be defined for integration tests"); 37 + } 38 + 39 + const databaseName = getDatabaseName(connectionString); 40 + const adminClient = new Client({ 41 + connectionString: getAdminDatabaseUrl(connectionString), 42 + }); 43 + 44 + await adminClient.connect(); 45 + 46 + try { 47 + const result = await adminClient.query( 48 + "SELECT 1 FROM pg_database WHERE datname = $1", 49 + [databaseName], 50 + ); 51 + 52 + if (result.rowCount === 0) { 53 + await adminClient.query( 54 + `CREATE DATABASE ${quoteIdentifier(databaseName)}`, 55 + ); 56 + } 57 + } finally { 58 + await adminClient.end(); 59 + } 60 + } 61 + 62 + export async function ensureTestDatabaseMigrated() { 63 + if (!migrationPromise) { 64 + migrationPromise = (async () => { 65 + await ensureTestDatabaseExists(); 66 + await migrate(db, { 67 + migrationsFolder, 68 + }); 69 + })(); 70 + } 71 + 72 + await migrationPromise; 73 + } 74 + 75 + export async function resetTestDatabase() { 76 + await ensureTestDatabaseMigrated(); 77 + 78 + await db.execute( 79 + sql.raw(` 80 + TRUNCATE TABLE 81 + "activity", 82 + "account", 83 + "apikey", 84 + "asset", 85 + "column", 86 + "comment", 87 + "external_link", 88 + "github_integration", 89 + "integration", 90 + "invitation", 91 + "label", 92 + "notification", 93 + "project", 94 + "session", 95 + "task", 96 + "task_relation", 97 + "team", 98 + "team_member", 99 + "time_entry", 100 + "verification", 101 + "workflow_rule", 102 + "workspace", 103 + "workspace_member", 104 + "user" 105 + RESTART IDENTITY CASCADE 106 + `), 107 + ); 108 + }
+169
tests/api-integration/project.test.ts
··· 1 + import { randomUUID } from "node:crypto"; 2 + import { eq } from "drizzle-orm"; 3 + import { beforeEach, describe, expect, it } from "vitest"; 4 + import db, { schema } from "../../apps/api/src/database"; 5 + import { createApp } from "../../apps/api/src/index"; 6 + import { mockAnonymousSession, mockAuthenticatedSession } from "./helpers/auth"; 7 + import { resetTestDatabase } from "./helpers/database"; 8 + 9 + type SeededMemberContext = { 10 + user: typeof schema.userTable.$inferSelect; 11 + workspace: typeof schema.workspaceTable.$inferSelect; 12 + }; 13 + 14 + async function createWorkspaceMember(): Promise<SeededMemberContext> { 15 + const userId = `user-${randomUUID()}`; 16 + const workspaceId = `workspace-${randomUUID()}`; 17 + 18 + const [user] = await db 19 + .insert(schema.userTable) 20 + .values({ 21 + id: userId, 22 + email: `${userId}@example.com`, 23 + emailVerified: true, 24 + name: "Integration Test User", 25 + }) 26 + .returning(); 27 + 28 + const [workspace] = await db 29 + .insert(schema.workspaceTable) 30 + .values({ 31 + id: workspaceId, 32 + createdAt: new Date(), 33 + name: "Integration Test Workspace", 34 + slug: `workspace-${randomUUID()}`, 35 + }) 36 + .returning(); 37 + 38 + await db.insert(schema.workspaceUserTable).values({ 39 + workspaceId: workspace.id, 40 + userId: user.id, 41 + role: "owner", 42 + joinedAt: new Date(), 43 + }); 44 + 45 + return { user, workspace }; 46 + } 47 + 48 + describe("API integration: project creation", () => { 49 + beforeEach(async () => { 50 + await resetTestDatabase(); 51 + }); 52 + 53 + it("rejects unauthenticated project creation requests", async () => { 54 + mockAnonymousSession(); 55 + const { app } = createApp(); 56 + 57 + const response = await app.request("/api/project", { 58 + method: "POST", 59 + headers: { 60 + "content-type": "application/json", 61 + }, 62 + body: JSON.stringify({ 63 + workspaceId: "workspace-missing", 64 + name: "Unauthorized Project", 65 + icon: "Folder", 66 + slug: "unauthorized-project", 67 + }), 68 + }); 69 + 70 + expect(response.status).toBe(401); 71 + await expect(response.text()).resolves.toBe("Unauthorized"); 72 + }); 73 + 74 + it("creates a project for a workspace member and seeds default columns", async () => { 75 + const member = await createWorkspaceMember(); 76 + mockAuthenticatedSession(member.user); 77 + const { app } = createApp(); 78 + 79 + const response = await app.request("/api/project", { 80 + method: "POST", 81 + headers: { 82 + "content-type": "application/json", 83 + }, 84 + body: JSON.stringify({ 85 + workspaceId: member.workspace.id, 86 + name: "Roadmap", 87 + icon: "FolderKanban", 88 + slug: "roadmap", 89 + }), 90 + }); 91 + 92 + expect(response.status).toBe(200); 93 + const payload = 94 + (await response.json()) as typeof schema.projectTable.$inferSelect; 95 + 96 + expect(payload).toMatchObject({ 97 + workspaceId: member.workspace.id, 98 + name: "Roadmap", 99 + icon: "FolderKanban", 100 + slug: "roadmap", 101 + }); 102 + 103 + const persistedProject = await db.query.projectTable.findFirst({ 104 + where: eq(schema.projectTable.id, payload.id), 105 + }); 106 + 107 + expect(persistedProject).toMatchObject({ 108 + id: payload.id, 109 + workspaceId: member.workspace.id, 110 + name: "Roadmap", 111 + slug: "roadmap", 112 + }); 113 + 114 + const columns = await db.query.columnTable.findMany({ 115 + where: eq(schema.columnTable.projectId, payload.id), 116 + orderBy: (column, { asc }) => [asc(column.position)], 117 + }); 118 + 119 + expect(columns).toHaveLength(4); 120 + expect(columns.map((column) => column.slug)).toEqual([ 121 + "to-do", 122 + "in-progress", 123 + "in-review", 124 + "done", 125 + ]); 126 + expect(columns.map((column) => column.isFinal)).toEqual([ 127 + false, 128 + false, 129 + false, 130 + true, 131 + ]); 132 + }); 133 + 134 + it("rejects project creation for users outside the workspace", async () => { 135 + const member = await createWorkspaceMember(); 136 + const outsiderId = `user-${randomUUID()}`; 137 + 138 + const [outsider] = await db 139 + .insert(schema.userTable) 140 + .values({ 141 + id: outsiderId, 142 + email: `${outsiderId}@example.com`, 143 + emailVerified: true, 144 + name: "Outsider", 145 + }) 146 + .returning(); 147 + 148 + mockAuthenticatedSession(outsider); 149 + const { app } = createApp(); 150 + 151 + const response = await app.request("/api/project", { 152 + method: "POST", 153 + headers: { 154 + "content-type": "application/json", 155 + }, 156 + body: JSON.stringify({ 157 + workspaceId: member.workspace.id, 158 + name: "Forbidden Project", 159 + icon: "Folder", 160 + slug: "forbidden-project", 161 + }), 162 + }); 163 + 164 + expect(response.status).toBe(403); 165 + await expect(response.text()).resolves.toBe( 166 + "You don't have access to this workspace", 167 + ); 168 + }); 169 + });
+37 -5
tests/api-integration/setup.ts
··· 1 - import { vi } from "vitest"; 1 + import { existsSync, readFileSync } from "node:fs"; 2 + import { dirname, resolve } from "node:path"; 3 + import { fileURLToPath } from "node:url"; 4 + import { afterEach, vi } from "vitest"; 5 + 6 + function deriveTestDatabaseUrl(connectionString: string) { 7 + const url = new URL(connectionString); 8 + const databaseName = url.pathname.replace(/^\//, ""); 9 + 10 + if (!databaseName || databaseName.endsWith("_test")) { 11 + return connectionString; 12 + } 2 13 3 - vi.mock("dotenv-mono", () => ({ 4 - config: () => ({}), 5 - })); 14 + url.pathname = `/${databaseName}_test`; 15 + return url.toString(); 16 + } 17 + 18 + function readDatabaseUrlFromEnvFile() { 19 + const currentDir = dirname(fileURLToPath(import.meta.url)); 20 + const envPath = resolve(currentDir, "../../.env"); 21 + 22 + if (!existsSync(envPath)) { 23 + return null; 24 + } 25 + 26 + const envFile = readFileSync(envPath, "utf8"); 27 + const match = envFile.match(/^DATABASE_URL=(.+)$/m); 28 + return match?.[1]?.trim() || null; 29 + } 6 30 7 31 process.env.NODE_ENV = "test"; 8 32 process.env.AUTH_SECRET = "test-secret-with-at-least-32-chars"; 9 33 process.env.DATABASE_URL = 10 - "postgresql://postgres:postgres@localhost:5432/kaneo_test"; 34 + process.env.DATABASE_URL || 35 + deriveTestDatabaseUrl( 36 + readDatabaseUrlFromEnvFile() || 37 + "postgresql://postgres:postgres@localhost:5432/kaneo_test", 38 + ); 11 39 process.env.KANEO_API_URL = "http://localhost:1337"; 12 40 process.env.KANEO_CLIENT_URL = "http://localhost:5173"; 13 41 process.env.DISABLE_GUEST_ACCESS = "false"; ··· 27 55 process.env.DISCORD_CLIENT_SECRET = ""; 28 56 process.env.CUSTOM_OAUTH_CLIENT_ID = ""; 29 57 process.env.CUSTOM_OAUTH_CLIENT_SECRET = ""; 58 + 59 + afterEach(() => { 60 + vi.restoreAllMocks(); 61 + });