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
30 changes: 26 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,18 +119,40 @@ Uses **Vitest** with a multi-project config (unit + integration). Tests run auto
**Writing new tests:**
- Unit tests: add `*.test.ts` files anywhere, use `happy-dom` environment
- Integration tests: add `*.integration.test.ts` files, use factories from `src/test/factories/`
- Use `createTestCaller(userId)` from `src/test/trpc-helpers.ts` to call tRPC procedures
- Use factory functions (`createUser`, `createWorkspace`, etc.) to set up test data
- Use `createMockCaller({ userId, db })` from `src/test/trpc-helpers.ts` for unit tests with mocked Prisma
- Use `createTestCaller(userId)` from `src/test/trpc-helpers.ts` for integration tests (real DB)
- Use factory functions (`createUser`, `createWorkspace`, etc.) to set up test data in integration tests

**Key test infrastructure files:**
- `vitest.config.ts` — multi-project config (unit + integration)
- `src/test/test-db.ts` — Testcontainers PostgreSQL setup + cleanup
- `src/test/test-db.ts` — Testcontainers PostgreSQL setup + cleanup (with safety guards)
- `src/test/integration-setup.ts` — mocks for Next.js/auth modules + DB lifecycle
- `src/test/factories/index.ts` — test data factories (user, workspace, project, action, etc.)
- `src/test/trpc-helpers.ts` — `createTestCaller()`, `createQueryCounter()`
- `src/test/trpc-helpers.ts` — `createTestCaller()`, `createMockCaller()`, `createQueryCounter()`

**CI:** GitHub Actions runs lint, unit tests, integration tests, and build on every PR (`.github/workflows/test.yml`)

### Test database safety

Integration tests should be RARE. Default to mocked Prisma (`mockDeep<PrismaClient>` from
`vitest-mock-extended`) and `createMockCaller({ userId, db })`. Only mark a test as
`*.integration.test.ts` when the test genuinely needs real DB behavior (raw SQL, cascade delete
behavior, schema validation).

`*.integration.test.ts` files run ONLY in CI (`npm run test:integration`), never locally via
`npm run test`. They use testcontainers exclusively. The historical fallback to `DATABASE_URL`
has been removed — tests will fail loudly rather than ever touch a real DB.

If you need to test against a specific DB, set `DATABASE_URL_TEST` to a local Postgres or
testcontainer URL only. The host must be `localhost` / `127.0.0.1` / `host.docker.internal`,
or the DB name must contain `"test"`, or the run will refuse. Managed-service hostnames
(Railway, Supabase, Neon, RDS, Aiven, Fly.io, DigitalOcean, Azure, GCP) are hard-blocked
and CANNOT be overridden by `ALLOW_NON_LOCAL_TEST_DB`.

DO NOT remove the safety guard, marker-table check, or row-count threshold in
`src/test/test-db.ts`. They exist because a previous incident wiped production via the now-
removed `?? process.env.DATABASE_URL` fallback path.

### Deployment
- **Automated Build Checks**: Pre-push git hook automatically runs `Vercel build` before pushing
- **Main Branch Protection**: Additional type checking (`npm run typecheck`) runs when pushing to main
Expand Down
30 changes: 30 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
"tsx": "^4.21.0",
"typescript": "^5.5.3",
"vitest": "^4.0.16",
"vitest-mock-extended": "^4.0.0",
"wait-on": "^7.2.0"
},
"ct3aMetadata": {
Expand Down
255 changes: 0 additions & 255 deletions src/server/api/routers/__tests__/action.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,259 +537,4 @@ describe("action router", () => {
expect(fresh?.kanbanStatus).toBeNull();
});
});

describe("restricted projects", () => {
it("getAll hides actions of a restricted project from a non-member workspace member", async () => {
const owner = await createUser(db);
const stranger = await createUser(db);
const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-restricted-hide" });
await addWorkspaceMember(db, ws.id, stranger.id, "member");
const project = await createProject(db, {
createdById: owner.id,
workspaceId: ws.id,
isRestricted: true,
});
await createAction(db, {
createdById: owner.id,
projectId: project.id,
name: "Hidden Action",
});

const strangerCaller = createTestCaller(stranger.id);
const actions = await strangerCaller.action.getAll({ workspaceId: ws.id });
expect(actions.find((a) => a.name === "Hidden Action")).toBeUndefined();
});

it("getAll shows actions of a restricted project to a ProjectMember", async () => {
const owner = await createUser(db);
const member = await createUser(db);
const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-restricted-pm" });
await addWorkspaceMember(db, ws.id, member.id, "member");
const project = await createProject(db, {
createdById: owner.id,
workspaceId: ws.id,
isRestricted: true,
});
await addProjectMember(db, project.id, member.id, "viewer");
await createAction(db, {
createdById: owner.id,
projectId: project.id,
name: "Visible Action",
});

const memberCaller = createTestCaller(member.id);
const actions = await memberCaller.action.getAll({ workspaceId: ws.id });
expect(actions.find((a) => a.name === "Visible Action")).toBeDefined();
});

it("getAll shows restricted-project actions to a workspace owner via escape hatch", async () => {
const owner = await createUser(db);
const projectCreator = await createUser(db);
const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-restricted-escape" });
await addWorkspaceMember(db, ws.id, projectCreator.id, "member");
const project = await createProject(db, {
createdById: projectCreator.id,
workspaceId: ws.id,
isRestricted: true,
});
await createAction(db, {
createdById: projectCreator.id,
projectId: project.id,
name: "Escape-hatch Action",
});

const ownerCaller = createTestCaller(owner.id);
const actions = await ownerCaller.action.getAll({ workspaceId: ws.id });
expect(actions.find((a) => a.name === "Escape-hatch Action")).toBeDefined();
});

it("getProjectActions denies a workspace member on a restricted project", async () => {
const owner = await createUser(db);
const stranger = await createUser(db);
const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-getproj-deny" });
await addWorkspaceMember(db, ws.id, stranger.id, "member");
const project = await createProject(db, {
createdById: owner.id,
workspaceId: ws.id,
isRestricted: true,
});

const strangerCaller = createTestCaller(stranger.id);
await expect(
strangerCaller.action.getProjectActions({ projectId: project.id }),
).rejects.toThrow(TRPCError);
});

it("getProjectActions allows a ProjectMember viewer on a restricted project", async () => {
const owner = await createUser(db);
const viewer = await createUser(db);
const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-getproj-pm" });
await addWorkspaceMember(db, ws.id, viewer.id, "member");
const project = await createProject(db, {
createdById: owner.id,
workspaceId: ws.id,
isRestricted: true,
});
await addProjectMember(db, project.id, viewer.id, "viewer");
await createAction(db, {
createdById: owner.id,
projectId: project.id,
name: "Listable Action",
});

const viewerCaller = createTestCaller(viewer.id);
const actions = await viewerCaller.action.getProjectActions({
projectId: project.id,
});
expect(actions.some((a) => a.name === "Listable Action")).toBe(true);
});

it("getById denies a workspace member when the action's project is restricted", async () => {
const owner = await createUser(db);
const stranger = await createUser(db);
const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-getbyid-restricted" });
await addWorkspaceMember(db, ws.id, stranger.id, "member");
const project = await createProject(db, {
createdById: owner.id,
workspaceId: ws.id,
isRestricted: true,
});
const action = await createAction(db, {
createdById: owner.id,
projectId: project.id,
});

const strangerCaller = createTestCaller(stranger.id);
await expect(
strangerCaller.action.getById({ id: action.id }),
).rejects.toThrow(TRPCError);
});

it("update allows a project editor on a restricted project", async () => {
const owner = await createUser(db);
const editor = await createUser(db);
const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-update-editor" });
await addWorkspaceMember(db, ws.id, editor.id, "member");
const project = await createProject(db, {
createdById: owner.id,
workspaceId: ws.id,
isRestricted: true,
});
await addProjectMember(db, project.id, editor.id, "editor");
const action = await createAction(db, {
createdById: owner.id,
projectId: project.id,
name: "Editable",
});

const editorCaller = createTestCaller(editor.id);
const updated = await editorCaller.action.update({
id: action.id,
name: "Renamed by editor",
});
expect(updated.name).toBe("Renamed by editor");
});

it("update denies a project viewer on a restricted project", async () => {
const owner = await createUser(db);
const viewer = await createUser(db);
const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-update-viewer" });
await addWorkspaceMember(db, ws.id, viewer.id, "member");
const project = await createProject(db, {
createdById: owner.id,
workspaceId: ws.id,
isRestricted: true,
});
await addProjectMember(db, project.id, viewer.id, "viewer");
const action = await createAction(db, {
createdById: owner.id,
projectId: project.id,
});

const viewerCaller = createTestCaller(viewer.id);
await expect(
viewerCaller.action.update({
id: action.id,
name: "Should fail",
}),
).rejects.toThrow(TRPCError);
});

it("flipping isRestricted: true hides previously-visible actions from a workspace member", async () => {
const owner = await createUser(db);
const member = await createUser(db);
const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-flip-hide" });
await addWorkspaceMember(db, ws.id, member.id, "member");
const project = await createProject(db, {
createdById: owner.id,
workspaceId: ws.id,
isRestricted: false,
});
await createAction(db, {
createdById: owner.id,
projectId: project.id,
name: "Initially visible",
});

const memberCaller = createTestCaller(member.id);
const before = await memberCaller.action.getAll({ workspaceId: ws.id });
expect(before.find((a) => a.name === "Initially visible")).toBeDefined();

const ownerCaller = createTestCaller(owner.id);
await ownerCaller.project.setRestricted({
projectId: project.id,
isRestricted: true,
});

const after = await memberCaller.action.getAll({ workspaceId: ws.id });
expect(after.find((a) => a.name === "Initially visible")).toBeUndefined();
});

it("bulkAssignProject denies moving actions into a restricted project the caller cannot edit", async () => {
const owner = await createUser(db);
const member = await createUser(db);
const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-bulkassign-deny" });
await addWorkspaceMember(db, ws.id, member.id, "member");
const restrictedProject = await createProject(db, {
createdById: owner.id,
workspaceId: ws.id,
isRestricted: true,
});
// member's own action (no project)
const myAction = await createAction(db, {
createdById: member.id,
name: "Mine",
});

await expect(
createTestCaller(member.id).action.bulkAssignProject({
actionIds: [myAction.id],
projectId: restrictedProject.id,
}),
).rejects.toThrow(TRPCError);
});

it("bulkAssignProject succeeds for a ProjectMember editor on a restricted project", async () => {
const owner = await createUser(db);
const editor = await createUser(db);
const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-bulkassign-ok" });
await addWorkspaceMember(db, ws.id, editor.id, "member");
const restrictedProject = await createProject(db, {
createdById: owner.id,
workspaceId: ws.id,
isRestricted: true,
});
await addProjectMember(db, restrictedProject.id, editor.id, "editor");
const myAction = await createAction(db, {
createdById: editor.id,
name: "Mine to move",
});

const result = await createTestCaller(editor.id).action.bulkAssignProject({
actionIds: [myAction.id],
projectId: restrictedProject.id,
});
expect(result.count).toBe(1);
});
});
});
Loading
Loading