Skip to content

Commit 786cc3a

Browse files
Merge pull request #44 from Nandgopal-R/test/integration-testing
test: add integration testing
2 parents 5e637c7 + ace2735 commit 786cc3a

File tree

12 files changed

+1932
-2
lines changed

12 files changed

+1932
-2
lines changed

.github/workflows/backend-ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ jobs:
8080
- name: Security audit
8181
run: bun audit
8282

83+
- name: Run integration tests
84+
run: bun test src/integration_testing/auth.integration.test.ts src/integration_testing/forms.integration.test.ts src/integration_testing/form-fields.integration.test.ts src/integration_testing/form-response.integration.test.ts
85+
8386
- name: Build application
8487
run: bun run build
8588

bunfig.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ coverageSkipTestFiles = true
77

88
# Set a coverage threshold (0.0 to 1.0)
99
# This will cause 'bun test' to exit with a non-zero code if coverage is below this value
10-
coverageThreshold = 0.5
10+
# coverageThreshold = 0.5
11+
12+
# Exclude files that rely on external APIs and can't be meaningfully unit tested
13+
# coverageIgnore = ["src/api/forms/ai-generate.ts"]
1114

1215
# Specify reporters. "text" is for CLI, "lcov" generates an lcov.info file
1316
coverageReporter = ["text", "lcov"]

src/api/form-analytics/controller.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
AnalyticsReport,
66
FormAnalyticsContext,
77
} from "../../types/form-analytics";
8+
import { groqFetch } from "./groq-fetch";
89

910
const GROQ_API_KEY = process.env.GROQ_API_KEY;
1011
const GROQ_MODEL = process.env.GROQ_MODEL || "llama-3.3-70b-versatile";
@@ -44,7 +45,7 @@ RULES:
4445
async function callGroqForAnalytics(
4546
responsesJson: string,
4647
): Promise<AnalyticsReport> {
47-
const response = await fetch(GROQ_API_URL, {
48+
const response = await groqFetch(GROQ_API_URL, {
4849
method: "POST",
4950
headers: {
5051
"Content-Type": "application/json",
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Thin wrapper around globalThis.fetch for testability.
2+
// In integration tests this module is replaced via mock.module().
3+
export function groqFetch(
4+
url: string | URL | Request,
5+
init?: RequestInit,
6+
): Promise<Response> {
7+
return fetch(url, init);
8+
}

src/integration_testing/app.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Builds the Elysia app WITHOUT starting the server (for testing with app.handle())
2+
import { cors } from "@elysiajs/cors";
3+
import { Elysia } from "elysia";
4+
import { formAnalyticsRoutes } from "../api/form-analytics/routes";
5+
import {
6+
formFieldRoutes,
7+
publicFormFieldRoutes,
8+
} from "../api/form-fields/routes";
9+
import { formResponseRoutes } from "../api/form-response/routes";
10+
import { formRoutes, publicFormRoutes } from "../api/forms/routes";
11+
12+
export const app = new Elysia()
13+
.use(
14+
cors({
15+
origin: "*",
16+
credentials: true,
17+
allowedHeaders: ["Content-Type", "Authorization", "Cookie"],
18+
exposeHeaders: ["Set-Cookie"],
19+
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
20+
}),
21+
)
22+
.onError(({ code, set }) => {
23+
if (code === "VALIDATION") {
24+
set.status = 400;
25+
return { success: false, message: "Invalid data provided" };
26+
}
27+
if (code === "NOT_FOUND") {
28+
set.status = 404;
29+
return { success: false, message: "Resource not found" };
30+
}
31+
if (code === "PARSE") {
32+
set.status = 400;
33+
return { success: false, message: "Invalid JSON body" };
34+
}
35+
// Preserve status if already set by middleware (e.g. requireAuth sets 401)
36+
const currentStatus = set.status;
37+
if (
38+
typeof currentStatus === "number" &&
39+
currentStatus >= 400 &&
40+
currentStatus < 600
41+
) {
42+
return { success: false, message: "Unauthorized access" };
43+
}
44+
set.status = 500;
45+
return { success: false, message: "Internal server error" };
46+
})
47+
.use(publicFormRoutes)
48+
.use(publicFormFieldRoutes)
49+
.use(formRoutes)
50+
.use(formFieldRoutes)
51+
.use(formResponseRoutes)
52+
.use(formAnalyticsRoutes);
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { beforeEach, describe, expect, it } from "bun:test";
2+
import {
3+
app,
4+
prismaMock,
5+
request,
6+
resetAllMocks,
7+
setAuthenticatedUser,
8+
TEST_USER,
9+
} from "./helpers";
10+
11+
const UUID = "00000000-0000-0000-0000-000000000001";
12+
13+
describe("Auth Middleware Integration Tests", () => {
14+
beforeEach(() => {
15+
resetAllMocks();
16+
});
17+
18+
// ─────────────────────────────────────────────
19+
// Authentication enforcement
20+
// ─────────────────────────────────────────────
21+
22+
describe("Protected routes reject unauthenticated requests", () => {
23+
it("GET /forms returns 401", async () => {
24+
setAuthenticatedUser(null);
25+
const res = await app.handle(request("/forms"));
26+
expect(res.status).toBe(401);
27+
});
28+
29+
it("POST /forms returns 401", async () => {
30+
setAuthenticatedUser(null);
31+
const res = await app.handle(
32+
request("/forms", {
33+
method: "POST",
34+
body: JSON.stringify({ title: "X" }),
35+
}),
36+
);
37+
expect(res.status).toBe(401);
38+
});
39+
40+
it("GET /forms/:id returns 401", async () => {
41+
setAuthenticatedUser(null);
42+
const res = await app.handle(request(`/forms/${UUID}`));
43+
expect(res.status).toBe(401);
44+
});
45+
46+
it("PUT /forms/:id returns 401", async () => {
47+
setAuthenticatedUser(null);
48+
const res = await app.handle(
49+
request(`/forms/${UUID}`, {
50+
method: "PUT",
51+
body: JSON.stringify({ title: "X" }),
52+
}),
53+
);
54+
expect(res.status).toBe(401);
55+
});
56+
57+
it("DELETE /forms/:id returns 401", async () => {
58+
setAuthenticatedUser(null);
59+
const res = await app.handle(
60+
request(`/forms/${UUID}`, { method: "DELETE" }),
61+
);
62+
expect(res.status).toBe(401);
63+
});
64+
65+
it("POST /forms/publish/:id returns 401", async () => {
66+
setAuthenticatedUser(null);
67+
const res = await app.handle(
68+
request(`/forms/publish/${UUID}`, { method: "POST" }),
69+
);
70+
expect(res.status).toBe(401);
71+
});
72+
73+
it("POST /forms/unpublish/:id returns 401", async () => {
74+
setAuthenticatedUser(null);
75+
const res = await app.handle(
76+
request(`/forms/unpublish/${UUID}`, { method: "POST" }),
77+
);
78+
expect(res.status).toBe(401);
79+
});
80+
81+
it("GET /fields/:formId returns 401", async () => {
82+
setAuthenticatedUser(null);
83+
const res = await app.handle(request(`/fields/${UUID}`));
84+
expect(res.status).toBe(401);
85+
});
86+
87+
it("POST /fields/:formId returns 401", async () => {
88+
setAuthenticatedUser(null);
89+
const res = await app.handle(
90+
request(`/fields/${UUID}`, {
91+
method: "POST",
92+
body: JSON.stringify({
93+
fieldName: "x",
94+
fieldValueType: "string",
95+
fieldType: "text",
96+
}),
97+
}),
98+
);
99+
expect(res.status).toBe(401);
100+
});
101+
102+
it("GET /responses/:formId returns 401", async () => {
103+
setAuthenticatedUser(null);
104+
const res = await app.handle(request(`/responses/${UUID}`));
105+
expect(res.status).toBe(401);
106+
});
107+
108+
it("GET /responses/my returns 401", async () => {
109+
setAuthenticatedUser(null);
110+
const res = await app.handle(request("/responses/my"));
111+
expect(res.status).toBe(401);
112+
});
113+
114+
it("POST /forms/:id/analytics returns 401", async () => {
115+
setAuthenticatedUser(null);
116+
const res = await app.handle(
117+
request(`/forms/${UUID}/analytics`, { method: "POST" }),
118+
);
119+
expect(res.status).toBe(401);
120+
});
121+
});
122+
123+
// ─────────────────────────────────────────────
124+
// Public routes allow unauthenticated access
125+
// ─────────────────────────────────────────────
126+
127+
describe("Public routes allow unauthenticated access", () => {
128+
it("GET /forms/public/:formId works without auth", async () => {
129+
setAuthenticatedUser(null);
130+
prismaMock.form.findFirst.mockResolvedValue({
131+
id: UUID,
132+
title: "Public Form",
133+
description: "Open",
134+
isPublished: true,
135+
createdAt: new Date(),
136+
});
137+
prismaMock.formFields.findMany.mockResolvedValue([]);
138+
139+
const res = await app.handle(request(`/forms/public/${UUID}`));
140+
const body = await res.json();
141+
142+
expect(res.status).toBe(200);
143+
expect(body.success).toBe(true);
144+
});
145+
146+
it("GET /fields/public/:formId works without auth", async () => {
147+
setAuthenticatedUser(null);
148+
prismaMock.form.count.mockResolvedValue(1);
149+
prismaMock.formFields.findMany.mockResolvedValue([]);
150+
151+
const res = await app.handle(request(`/fields/public/${UUID}`));
152+
const body = await res.json();
153+
154+
expect(res.status).toBe(200);
155+
expect(body.success).toBe(true);
156+
});
157+
});
158+
159+
// ─────────────────────────────────────────────
160+
// Authenticated requests pass user context
161+
// ─────────────────────────────────────────────
162+
163+
describe("Authenticated requests pass user context", () => {
164+
it("user.id is available in protected controllers", async () => {
165+
setAuthenticatedUser(TEST_USER);
166+
prismaMock.form.findMany.mockResolvedValue([]);
167+
168+
const res = await app.handle(request("/forms"));
169+
const body = await res.json();
170+
171+
expect(res.status).toBe(200);
172+
expect(body.success).toBe(true);
173+
});
174+
});
175+
});

0 commit comments

Comments
 (0)