test(qa): add e2e, integration, unit, and load coverage

Swadhin Biswas committed 7 months ago
1c29e63
1 parent 28f7954
commit 1c29e63
54 files changed
+4519 -1
Showing 54 changed files+4519-1
tests/e2e/accessibility.spec.tstests/e2e/admin-panel.spec.tstests/e2e/api-smoke.spec.tstests/e2e/auth-login.spec.tstests/e2e/auth-register.spec.tstests/e2e/blame-view.spec.tstests/e2e/compare-view.spec.tstests/e2e/create-repo.spec.tstests/e2e/dashboard.spec.tstests/e2e/fork-repo.spec.tstests/e2e/issues.spec.tstests/e2e/labels-milestones.spec.tstests/e2e/mobile-nav.spec.tstests/e2e/notifications.spec.tstests/e2e/org-management.spec.tstests/e2e/pull-requests.spec.tstests/e2e/releases.spec.tstests/e2e/search.spec.tstests/e2e/theme-toggle.spec.tstests/e2e/webhook-setup.spec.tstests/integration/admin-stats-route.test.tstests/integration/auth-login-route.test.tstests/integration/auth-me-route.test.tstests/integration/auth-register-route.test.tstests/integration/issues-releases-route.test.tstests/integration/notifications-route.test.tstests/integration/org-members-route.test.tstests/integration/search-route.test.tstests/integration/user-route.test.tstests/load/README.mdtests/load/k6-api-lifecycle.jstests/load/k6-load.jstests/load/k6-smoke.jstests/load/k6-spike.jstests/load/k6-stress.jstests/unit/air-gapped.test.tstests/unit/api-helpers.test.tstests/unit/ci-formats.test.tstests/unit/cn.test.tstests/unit/codeowners.test.tstests/unit/email.test.tstests/unit/env-validation.test.tstests/unit/markdown.test.tstests/unit/notification-routing.test.tstests/unit/notifications-route.test.tstests/unit/openapi-notifications-parity.test.tstests/unit/permissions.test.tstests/unit/runner-registration-token.test.tstests/unit/sanitize.test.tstests/unit/security-policies.test.tstests/unit/suggested-changes.test.tstests/unit/url-validator.test.tstests/unit/validation.test.tstests/unit/workflow-secret-crypto.test.ts
▼tests/e2e/accessibility.spec.ts
+257-0
1
+/**
2
+ * Accessibility E2E Tests — WCAG 2.1 AA compliance
3
+ *
4
+ * Uses @axe-core/playwright to automatically detect accessibility violations
5
+ * across key pages. Covers:
6
+ * - WCAG 2.4.1: Skip navigation / bypass blocks
7
+ * - WCAG 1.1.1: Non-text content (alt text, aria-labels)
8
+ * - WCAG 1.3.1: Info and relationships (semantic HTML, ARIA roles)
9
+ * - WCAG 1.4.3: Contrast (minimum 4.5:1)
10
+ * - WCAG 2.1.1: Keyboard navigable
11
+ * - WCAG 4.1.2: Name, role, value (ARIA attributes)
12
+ */
13
+import AxeBuilder from "@axe-core/playwright";
14
+import { expect, test, type Page } from "@playwright/test";
15
+
16
+interface AxeViolation {
17
+ id: string;
18
+ impact?: string;
19
+ description: string;
20
+ nodes: unknown[];
21
+}
22
+
23
+// Pages to test for accessibility
24
+const PUBLIC_PAGES = [
25
+ { name: "Home", path: "/" },
26
+ { name: "Explore", path: "/explore" },
27
+ { name: "Login", path: "/login" },
28
+ { name: "Register", path: "/register" },
29
+ { name: "Documentation", path: "/docs/" },
30
+];
31
+
32
+/**
33
+ * Run axe-core on a page and return the results.
34
+ * Tags filter ensures we check WCAG 2.1 AA rules.
35
+ */
36
+async function runAxe(page: Page) {
37
+ return new AxeBuilder({ page })
38
+ .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
39
+ .exclude(".toaster") // Exclude transient toast notifications
40
+ .analyze();
41
+}
42
+
43
+test.describe("Accessibility — WCAG 2.1 AA compliance", () => {
44
+ for (const { name, path } of PUBLIC_PAGES) {
45
+ test(`${name} page (${path}) has no critical accessibility violations`, async ({
46
+ page,
47
+ }) => {
48
+ await page.goto(path, { waitUntil: "networkidle" });
49
+
50
+ const results = await runAxe(page);
51
+
52
+ // Filter to critical and serious violations only
53
+ const critical = (results.violations as AxeViolation[]).filter(
54
+ (v: AxeViolation) => v.impact === "critical" || v.impact === "serious",
55
+ );
56
+
57
+ if (critical.length > 0) {
58
+ const summary = critical
59
+ .map(
60
+ (v: AxeViolation) =>
61
+ `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} occurrences)`,
62
+ )
63
+ .join("\n");
64
+ console.error(`Accessibility violations on ${name}:\n${summary}`);
65
+ }
66
+
67
+ expect(
68
+ critical,
69
+ `Expected no critical/serious a11y violations on ${name}`,
70
+ ).toHaveLength(0);
71
+ });
72
+ }
73
+
74
+ test("Skip-to-content link is present and functional", async ({ page }) => {
75
+ await page.goto("/", { waitUntil: "networkidle" });
76
+
77
+ // The skip link should exist
78
+ const skipLink = page.locator('a[href="#main-content"]');
79
+ await expect(skipLink).toBeAttached();
80
+
81
+ // Should be visually hidden by default
82
+ const box = await skipLink.boundingBox();
83
+ // sr-only makes it 1x1px or off-screen — either way not visible
84
+ expect(box === null || box.width <= 1 || box.height <= 1).toBeTruthy();
85
+
86
+ // Focus the skip link and verify it becomes visible
87
+ await skipLink.focus();
88
+ await expect(skipLink).toBeFocused();
89
+
90
+ // After focus, the sr-only class should be overridden by focus:not-sr-only
91
+ const focusedBox = await skipLink.boundingBox();
92
+ expect(focusedBox).not.toBeNull();
93
+ if (focusedBox) {
94
+ expect(focusedBox.width).toBeGreaterThan(10);
95
+ expect(focusedBox.height).toBeGreaterThan(10);
96
+ }
97
+
98
+ // Main content target should exist
99
+ const mainContent = page.locator("#main-content");
100
+ await expect(mainContent).toBeAttached();
101
+ await expect(mainContent).toHaveAttribute("role", "main");
102
+ });
103
+
104
+ test("Header has proper ARIA landmarks and roles", async ({ page }) => {
105
+ await page.goto("/", { waitUntil: "networkidle" });
106
+
107
+ // Banner role on header
108
+ const header = page.locator('header[role="banner"]');
109
+ await expect(header).toBeAttached();
110
+
111
+ // Main navigation
112
+ const mainNav = page.locator('nav[aria-label="Main navigation"]');
113
+ await expect(mainNav).toBeAttached();
114
+
115
+ // Logo has accessible label
116
+ const logoLink = page.locator('a[aria-label="OpenCodeHub home"]');
117
+ await expect(logoLink).toBeAttached();
118
+
119
+ // Search region
120
+ const searchRegion = page.locator('[role="search"]');
121
+ await expect(searchRegion).toBeAttached();
122
+ });
123
+
124
+ test("Footer has proper semantic structure", async ({ page }) => {
125
+ await page.goto("/", { waitUntil: "networkidle" });
126
+
127
+ // Footer with contentinfo role
128
+ const footer = page.locator('footer[role="contentinfo"]');
129
+ await expect(footer).toBeAttached();
130
+
131
+ // Footer navigation
132
+ const footerNav = page.locator('nav[aria-label="Footer navigation"]');
133
+ await expect(footerNav).toBeAttached();
134
+
135
+ // External link has proper labeling
136
+ const githubLink = page.locator(
137
+ 'a[aria-label="GitHub repository (opens in new tab)"]',
138
+ );
139
+ await expect(githubLink).toBeAttached();
140
+ await expect(githubLink).toHaveAttribute("target", "_blank");
141
+ await expect(githubLink).toHaveAttribute("rel", /noopener/);
142
+ });
143
+
144
+ test("Dropdown menus have proper ARIA attributes", async ({ page }) => {
145
+ await page.goto("/", { waitUntil: "networkidle" });
146
+
147
+ // Create menu button
148
+ const createBtn = page.locator('#create-menu button[aria-haspopup="true"]');
149
+ // May not be visible if user not logged in — check if present
150
+ const createBtnCount = await createBtn.count();
151
+
152
+ if (createBtnCount > 0) {
153
+ await expect(createBtn).toHaveAttribute("aria-expanded", "false");
154
+ await expect(createBtn).toHaveAttribute(
155
+ "aria-controls",
156
+ "create-dropdown",
157
+ );
158
+
159
+ // The dropdown should have role="menu"
160
+ const createDropdown = page.locator('#create-dropdown[role="menu"]');
161
+ await expect(createDropdown).toBeAttached();
162
+
163
+ // Menu items should have role="menuitem"
164
+ const menuItems = createDropdown.locator('[role="menuitem"]');
165
+ expect(await menuItems.count()).toBeGreaterThan(0);
166
+ }
167
+ });
168
+
169
+ test("Mobile menu button has proper ARIA attributes", async ({ page }) => {
170
+ // Set mobile viewport
171
+ await page.setViewportSize({ width: 375, height: 812 });
172
+ await page.goto("/", { waitUntil: "networkidle" });
173
+
174
+ const mobileBtn = page.locator("#mobile-menu-btn");
175
+ await expect(mobileBtn).toBeVisible();
176
+ await expect(mobileBtn).toHaveAttribute("aria-label", "Menu");
177
+ await expect(mobileBtn).toHaveAttribute("aria-expanded", "false");
178
+
179
+ // Click to open
180
+ await mobileBtn.click();
181
+ await expect(mobileBtn).toHaveAttribute("aria-expanded", "true");
182
+
183
+ // Mobile nav should be visible
184
+ const mobileNav = page.locator("#mobile-nav");
185
+ await expect(mobileNav).toBeVisible();
186
+ await expect(mobileNav).toHaveAttribute("role", "dialog");
187
+ });
188
+
189
+ test("Keyboard navigation: Escape closes open dropdowns", async ({
190
+ page,
191
+ }) => {
192
+ await page.goto("/", { waitUntil: "networkidle" });
193
+
194
+ // Mobile viewport to test mobile menu
195
+ await page.setViewportSize({ width: 375, height: 812 });
196
+
197
+ const mobileBtn = page.locator("#mobile-menu-btn");
198
+ await mobileBtn.click();
199
+
200
+ const mobileNav = page.locator("#mobile-nav");
201
+ await expect(mobileNav).toBeVisible();
202
+
203
+ // Press Escape
204
+ await page.keyboard.press("Escape");
205
+ await expect(mobileNav).not.toBeVisible();
206
+ await expect(mobileBtn).toHaveAttribute("aria-expanded", "false");
207
+ });
208
+
209
+ test("All images have alt text", async ({ page }) => {
210
+ await page.goto("/", { waitUntil: "networkidle" });
211
+
212
+ const images = page.locator("img");
213
+ const count = await images.count();
214
+
215
+ for (let i = 0; i < count; i++) {
216
+ const img = images.nth(i);
217
+ const alt = await img.getAttribute("alt");
218
+ const src = await img.getAttribute("src");
219
+ expect(
220
+ alt !== null && alt !== undefined,
221
+ `Image ${src} is missing alt attribute`,
222
+ ).toBeTruthy();
223
+ }
224
+ });
225
+
226
+ test("Interactive elements are keyboard focusable", async ({ page }) => {
227
+ await page.goto("/", { waitUntil: "networkidle" });
228
+
229
+ // Tab through the page and ensure we can reach key elements
230
+ await page.keyboard.press("Tab");
231
+ // First tab should hit the skip-to-content link
232
+ const skipLink = page.locator('a[href="#main-content"]');
233
+ await expect(skipLink).toBeFocused();
234
+
235
+ // Continue tabbing — should reach the logo
236
+ await page.keyboard.press("Tab");
237
+ const logo = page.locator('a[aria-label="OpenCodeHub home"]');
238
+ await expect(logo).toBeFocused();
239
+ });
240
+
241
+ test("Page has exactly one main landmark", async ({ page }) => {
242
+ await page.goto("/", { waitUntil: "networkidle" });
243
+
244
+ const mainElements = page.locator("main, [role='main']");
245
+ // Should have exactly 1 main landmark (our <main id="main-content" role="main">)
246
+ const count = await mainElements.count();
247
+ // It's the same element, so count should be 1
248
+ expect(count).toBe(1);
249
+ });
250
+
251
+ test("HTML lang attribute is set", async ({ page }) => {
252
+ await page.goto("/", { waitUntil: "networkidle" });
253
+
254
+ const lang = await page.locator("html").getAttribute("lang");
255
+ expect(lang).toBe("en");
256
+ });
257
+});
0
258
▼tests/e2e/admin-panel.spec.ts
+29-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Admin Panel", () => {
4
+ test("admin page requires auth", async ({ page }) => {
5
+ await page.goto("/admin");
6
+ const url = page.url();
7
+ // Should redirect to login or show forbidden
8
+ expect(
9
+ url.includes("/login") ||
10
+ (await page
11
+ .locator("text=Forbidden")
12
+ .or(page.locator("text=denied"))
13
+ .first()
14
+ .isVisible()
15
+ .catch(() => false)) ||
16
+ (await page
17
+ .locator("text=Login")
18
+ .first()
19
+ .isVisible()
20
+ .catch(() => false)),
21
+ ).toBeTruthy();
22
+ });
23
+
24
+ test("admin API requires admin role", async ({ request }) => {
25
+ const res = await request.get("/api/admin/stats");
26
+ // Should be 401 or 403
27
+ expect([401, 403]).toContain(res.status());
28
+ });
29
+});
0
30
▼tests/e2e/api-smoke.spec.ts
+54-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("API Smoke Tests", () => {
4
+ test("search API returns 200 for valid query", async ({ request }) => {
5
+ const res = await request.get("/api/search?q=test");
6
+ expect(res.status()).toBe(200);
7
+ const json = await res.json();
8
+ expect(json.results).toBeDefined();
9
+ expect(Array.isArray(json.results)).toBe(true);
10
+ });
11
+
12
+ test("search API returns empty for short query", async ({ request }) => {
13
+ const res = await request.get("/api/search?q=a");
14
+ expect(res.status()).toBe(200);
15
+ const json = await res.json();
16
+ expect(json.results).toEqual([]);
17
+ });
18
+
19
+ test("auth me API returns 401 without token", async ({ request }) => {
20
+ const res = await request.get("/api/auth/me");
21
+ expect(res.status()).toBe(401);
22
+ });
23
+
24
+ test("user API returns 401 without token", async ({ request }) => {
25
+ const res = await request.get("/api/user");
26
+ expect(res.status()).toBe(401);
27
+ });
28
+
29
+ test("notifications API returns 401 without token", async ({ request }) => {
30
+ const res = await request.get("/api/notifications");
31
+ expect(res.status()).toBe(401);
32
+ });
33
+
34
+ test("login API rejects empty body", async ({ request }) => {
35
+ const res = await request.post("/api/auth/login", {
36
+ data: {},
37
+ headers: { "Content-Type": "application/json" },
38
+ });
39
+ expect([400, 401]).toContain(res.status());
40
+ });
41
+
42
+ test("register API rejects invalid data", async ({ request }) => {
43
+ const res = await request.post("/api/auth/register", {
44
+ data: { username: "a" }, // too short, missing fields
45
+ headers: { "Content-Type": "application/json" },
46
+ });
47
+ expect([400, 422]).toContain(res.status());
48
+ });
49
+
50
+ test("CSRF token endpoint exists", async ({ request }) => {
51
+ const res = await request.get("/api/auth/csrf-token");
52
+ expect([200, 404]).toContain(res.status());
53
+ });
54
+});
0
55
▼tests/e2e/auth-login.spec.ts
+52-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Authentication - Login", () => {
4
+ test("login page loads", async ({ page }) => {
5
+ await page.goto("/login");
6
+ await expect(page.locator("form")).toBeVisible();
7
+ await expect(
8
+ page
9
+ .locator(
10
+ 'input[name="login"], input[name="username"], input[type="text"]',
11
+ )
12
+ .first(),
13
+ ).toBeVisible();
14
+ await expect(
15
+ page.locator('input[name="password"], input[type="password"]').first(),
16
+ ).toBeVisible();
17
+ });
18
+
19
+ test("shows error for invalid credentials", async ({ page }) => {
20
+ await page.goto("/login");
21
+ await page.fill(
22
+ 'input[name="login"], input[name="username"], input[type="text"]',
23
+ "nonexistent_user",
24
+ );
25
+ await page.fill(
26
+ 'input[name="password"], input[type="password"]',
27
+ "wrongpassword",
28
+ );
29
+ await page.click('button[type="submit"]');
30
+ await expect(
31
+ page
32
+ .locator("text=Invalid")
33
+ .or(page.locator("text=incorrect"))
34
+ .or(page.locator("[role=alert]"))
35
+ .first(),
36
+ ).toBeVisible({ timeout: 5000 });
37
+ });
38
+
39
+ test("has link to register page", async ({ page }) => {
40
+ await page.goto("/login");
41
+ const registerLink = page.locator('a[href*="register"]');
42
+ await expect(registerLink).toBeVisible();
43
+ });
44
+
45
+ test("has link to forgot password", async ({ page }) => {
46
+ await page.goto("/login");
47
+ const forgotLink = page.locator('a[href*="forgot"], a[href*="reset"]');
48
+ if (await forgotLink.isVisible()) {
49
+ await expect(forgotLink).toBeVisible();
50
+ }
51
+ });
52
+});
0
53
▼tests/e2e/auth-register.spec.ts
+30-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Authentication - Register", () => {
4
+ test("register page loads", async ({ page }) => {
5
+ await page.goto("/register");
6
+ await expect(page.locator("form")).toBeVisible();
7
+ });
8
+
9
+ test("shows validation errors for empty form", async ({ page }) => {
10
+ await page.goto("/register");
11
+ await page.click('button[type="submit"]');
12
+ await expect(
13
+ page.locator("text=required").or(page.locator("[role=alert]")).first(),
14
+ ).toBeVisible({ timeout: 5000 });
15
+ });
16
+
17
+ test("register with valid data", async ({ page }) => {
18
+ const ts = Date.now();
19
+ await page.goto("/register");
20
+ await page.fill('input[name="username"]', `e2euser${ts}`);
21
+ await page.fill('input[name="email"]', `e2e${ts}@test.com`);
22
+ await page.fill('input[name="password"]', `Test1234!`);
23
+ if (await page.locator('input[name="displayName"]').isVisible()) {
24
+ await page.fill('input[name="displayName"]', `E2E User ${ts}`);
25
+ }
26
+ await page.click('button[type="submit"]');
27
+ // Should redirect to dashboard or show success
28
+ await page.waitForURL(/\/(dashboard|explore|\/)/, { timeout: 10000 });
29
+ });
30
+});
0
31
▼tests/e2e/blame-view.spec.ts
+10-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Blame View", () => {
4
+ test("blame page pattern exists", async ({ page }) => {
5
+ // Navigate to a blame URL pattern - may 404 if no repos exist
6
+ await page.goto("/test/test-repo/blame/main/README.md");
7
+ const status = await page.locator("body").isVisible();
8
+ expect(status).toBe(true);
9
+ });
10
+});
0
11
▼tests/e2e/compare-view.spec.ts
+9-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Compare View", () => {
4
+ test("compare page pattern exists", async ({ page }) => {
5
+ await page.goto("/test/test-repo/compare");
6
+ const status = await page.locator("body").isVisible();
7
+ expect(status).toBe(true);
8
+ });
9
+});
0
10
▼tests/e2e/create-repo.spec.ts
+26-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Create Repository", () => {
4
+ test("new repo page requires auth", async ({ page }) => {
5
+ await page.goto("/new");
6
+ // Should either show the form (if logged in) or redirect to login
7
+ const url = page.url();
8
+ const hasForm = await page
9
+ .locator("form")
10
+ .isVisible()
11
+ .catch(() => false);
12
+ expect(url.includes("/login") || hasForm).toBeTruthy();
13
+ });
14
+
15
+ test("new repo page shows required fields", async ({ page }) => {
16
+ // Navigate directly - may redirect if not authed
17
+ await page.goto("/new");
18
+ if (page.url().includes("/login")) {
19
+ // Expected for unauthenticated user
20
+ return;
21
+ }
22
+ await expect(
23
+ page.locator('input[name="name"], input[name="repoName"]').first(),
24
+ ).toBeVisible();
25
+ });
26
+});
0
27
▼tests/e2e/dashboard.spec.ts
+35-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Dashboard", () => {
4
+ test("homepage loads and shows branding", async ({ page }) => {
5
+ await page.goto("/");
6
+ await expect(page).toHaveTitle(/OpenCodeHub/i);
7
+ });
8
+
9
+ test("explore page loads", async ({ page }) => {
10
+ await page.goto("/explore");
11
+ await expect(page.locator("body")).toBeVisible();
12
+ // Should show repositories or search
13
+ await expect(
14
+ page
15
+ .locator("text=Explore")
16
+ .or(page.locator("text=Repositories"))
17
+ .or(page.locator("text=explore"))
18
+ .first(),
19
+ ).toBeVisible({ timeout: 5000 });
20
+ });
21
+
22
+ test("navigation bar is present", async ({ page }) => {
23
+ await page.goto("/");
24
+ const nav = page.locator("nav, header");
25
+ await expect(nav.first()).toBeVisible();
26
+ });
27
+
28
+ test("footer is present", async ({ page }) => {
29
+ await page.goto("/");
30
+ const footer = page.locator("footer");
31
+ if (await footer.isVisible()) {
32
+ await expect(footer).toBeVisible();
33
+ }
34
+ });
35
+});
0
36
▼tests/e2e/fork-repo.spec.ts
+16-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Fork Repository", () => {
4
+ test("fork requires authentication", async ({ page }) => {
5
+ await page.goto("/test/test-repo");
6
+ // Fork button should require auth
7
+ const forkBtn = page.locator("button:has-text('Fork'), a:has-text('Fork')");
8
+ if (await forkBtn.isVisible().catch(() => false)) {
9
+ await forkBtn.click();
10
+ // Should redirect to login
11
+ await page.waitForTimeout(1000);
12
+ const url = page.url();
13
+ expect(url.includes("/login") || url.includes("/fork")).toBeTruthy();
14
+ }
15
+ });
16
+});
0
17
▼tests/e2e/issues.spec.ts
+25-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Issues", () => {
4
+ test("issues page loads for a repo", async ({ page }) => {
5
+ // Navigate to a generic issues URL pattern
6
+ const response = await page.goto("/explore");
7
+ await expect(page.locator("body")).toBeVisible();
8
+ });
9
+
10
+ test("new issue page requires auth", async ({ page }) => {
11
+ // Attempting to create an issue without auth should redirect or show error
12
+ await page.goto("/test/test-repo/issues/new");
13
+ const url = page.url();
14
+ const hasError = await page
15
+ .locator("text=not found")
16
+ .or(page.locator("text=login"))
17
+ .first()
18
+ .isVisible()
19
+ .catch(() => false);
20
+ // Either redirected to login or shows not found (expected)
21
+ expect(
22
+ url.includes("/login") || hasError || page.url().includes("404"),
23
+ ).toBeTruthy();
24
+ });
25
+});
0
26
▼tests/e2e/labels-milestones.spec.ts
+13-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Labels and Milestones", () => {
4
+ test("labels page pattern", async ({ page }) => {
5
+ await page.goto("/test/test-repo/labels");
6
+ await expect(page.locator("body")).toBeVisible();
7
+ });
8
+
9
+ test("milestones page pattern", async ({ page }) => {
10
+ await page.goto("/test/test-repo/milestones");
11
+ await expect(page.locator("body")).toBeVisible();
12
+ });
13
+});
0
14
▼tests/e2e/mobile-nav.spec.ts
+50-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Mobile Navigation", () => {
4
+ test.use({ viewport: { width: 375, height: 667 } }); // iPhone SE
5
+
6
+ test("mobile menu button is visible", async ({ page }) => {
7
+ await page.goto("/");
8
+ const menuBtn = page.locator(
9
+ 'button[aria-label*="menu"], button[aria-label*="Menu"], [data-mobile-menu], button.md\\:hidden, button.lg\\:hidden',
10
+ );
11
+ await expect(menuBtn.first()).toBeVisible({ timeout: 5000 });
12
+ });
13
+
14
+ test("clicking menu button shows navigation", async ({ page }) => {
15
+ await page.goto("/");
16
+ const menuBtn = page
17
+ .locator(
18
+ 'button[aria-label*="menu"], button[aria-label*="Menu"], [data-mobile-menu], button.md\\:hidden, button.lg\\:hidden',
19
+ )
20
+ .first();
21
+
22
+ if (await menuBtn.isVisible()) {
23
+ await menuBtn.click();
24
+ await page.waitForTimeout(500);
25
+ // Navigation links should become visible
26
+ const navLinks = page.locator(
27
+ "nav a, [role=navigation] a, [data-mobile-nav] a",
28
+ );
29
+ await expect(navLinks.first()).toBeVisible({ timeout: 3000 });
30
+ }
31
+ });
32
+
33
+ test("page is responsive at mobile width", async ({ page }) => {
34
+ await page.goto("/");
35
+ // No horizontal scrollbar
36
+ const bodyWidth = await page.evaluate(() => document.body.scrollWidth);
37
+ const viewportWidth = await page.evaluate(() => window.innerWidth);
38
+ expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 5);
39
+ });
40
+
41
+ test("login page is mobile-friendly", async ({ page }) => {
42
+ await page.goto("/login");
43
+ await expect(page.locator("form")).toBeVisible();
44
+ // Form should fit within viewport
45
+ const formBox = await page.locator("form").boundingBox();
46
+ if (formBox) {
47
+ expect(formBox.width).toBeLessThanOrEqual(375);
48
+ }
49
+ });
50
+});
0
51
▼tests/e2e/notifications.spec.ts
+23-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Notifications", () => {
4
+ test("notifications API requires auth", async ({ request }) => {
5
+ const res = await request.get("/api/notifications");
6
+ expect(res.status()).toBe(401);
7
+ });
8
+
9
+ test("notifications page requires auth", async ({ page }) => {
10
+ await page.goto("/notifications");
11
+ const url = page.url();
12
+ expect(
13
+ url.includes("/login") ||
14
+ (await page
15
+ .locator("text=login")
16
+ .or(page.locator("text=Sign in"))
17
+ .first()
18
+ .isVisible()
19
+ .catch(() => false)) ||
20
+ page.url().includes("404"),
21
+ ).toBeTruthy();
22
+ });
23
+});
0
24
▼tests/e2e/org-management.spec.ts
+23-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Organization Management", () => {
4
+ test("org creation requires auth", async ({ page }) => {
5
+ await page.goto("/orgs/new");
6
+ const url = page.url();
7
+ expect(
8
+ url.includes("/login") ||
9
+ (await page
10
+ .locator("text=login")
11
+ .or(page.locator("text=Sign in"))
12
+ .first()
13
+ .isVisible()
14
+ .catch(() => false)) ||
15
+ page.url().includes("404"),
16
+ ).toBeTruthy();
17
+ });
18
+
19
+ test("org members API requires auth", async ({ request }) => {
20
+ const res = await request.get("/api/orgs/test-org/members");
21
+ expect([401, 403, 404]).toContain(res.status());
22
+ });
23
+});
0
24
▼tests/e2e/pull-requests.spec.ts
+22-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Pull Requests", () => {
4
+ test("PR list page pattern exists", async ({ page }) => {
5
+ await page.goto("/explore");
6
+ await expect(page.locator("body")).toBeVisible();
7
+ });
8
+
9
+ test("PR creation requires auth", async ({ page }) => {
10
+ await page.goto("/test/test-repo/compare");
11
+ const url = page.url();
12
+ // Should redirect to login or show 404
13
+ expect(
14
+ url.includes("/login") ||
15
+ url.includes("404") ||
16
+ (await page
17
+ .locator("text=not found")
18
+ .isVisible()
19
+ .catch(() => true)),
20
+ ).toBeTruthy();
21
+ });
22
+});
0
23
▼tests/e2e/releases.spec.ts
+15-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Releases", () => {
4
+ test("releases page pattern loads", async ({ page }) => {
5
+ await page.goto("/explore");
6
+ await expect(page.locator("body")).toBeVisible();
7
+ });
8
+
9
+ test("releases API returns data", async ({ request }) => {
10
+ // Public repo releases should be accessible
11
+ const res = await request.get("/api/repos/test/test-repo/releases");
12
+ // Could be 200 (with empty array) or 404 if repo doesn't exist
13
+ expect([200, 404]).toContain(res.status());
14
+ });
15
+});
0
16
▼tests/e2e/search.spec.ts
+37-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Search", () => {
4
+ test("search page or search bar exists", async ({ page }) => {
5
+ await page.goto("/");
6
+ const searchInput = page.locator(
7
+ 'input[type="search"], input[placeholder*="Search"], input[placeholder*="search"], [role="search"] input',
8
+ );
9
+ // Search might be in nav or on explore page
10
+ if (
11
+ !(await searchInput
12
+ .first()
13
+ .isVisible()
14
+ .catch(() => false))
15
+ ) {
16
+ await page.goto("/explore");
17
+ }
18
+ await expect(searchInput.first()).toBeVisible({ timeout: 5000 });
19
+ });
20
+
21
+ test("search returns results for common term", async ({ page }) => {
22
+ await page.goto("/explore");
23
+ const searchInput = page
24
+ .locator(
25
+ 'input[type="search"], input[placeholder*="Search"], input[placeholder*="search"]',
26
+ )
27
+ .first();
28
+ if (await searchInput.isVisible()) {
29
+ await searchInput.fill("test");
30
+ await searchInput.press("Enter");
31
+ // Wait for results to load
32
+ await page.waitForTimeout(1000);
33
+ // Page should still be visible (no crash)
34
+ await expect(page.locator("body")).toBeVisible();
35
+ }
36
+ });
37
+});
0
38
▼tests/e2e/theme-toggle.spec.ts
+52-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Theme Toggle", () => {
4
+ test("theme toggle button exists", async ({ page }) => {
5
+ await page.goto("/");
6
+ const themeBtn = page.locator(
7
+ 'button[aria-label*="theme"], button[aria-label*="Theme"], [data-theme-toggle], button:has(svg.lucide-sun), button:has(svg.lucide-moon)',
8
+ );
9
+ await expect(themeBtn.first()).toBeVisible({ timeout: 5000 });
10
+ });
11
+
12
+ test("clicking theme toggle changes theme", async ({ page }) => {
13
+ await page.goto("/");
14
+ const html = page.locator("html");
15
+ const initialClass = await html.getAttribute("class");
16
+
17
+ const themeBtn = page
18
+ .locator(
19
+ 'button[aria-label*="theme"], button[aria-label*="Theme"], [data-theme-toggle], button:has(svg.lucide-sun), button:has(svg.lucide-moon)',
20
+ )
21
+ .first();
22
+
23
+ if (await themeBtn.isVisible()) {
24
+ await themeBtn.click();
25
+ await page.waitForTimeout(500);
26
+ const newClass = await html.getAttribute("class");
27
+ // Theme class should change (dark ↔ light)
28
+ expect(newClass).not.toBe(initialClass);
29
+ }
30
+ });
31
+
32
+ test("theme persists on reload", async ({ page }) => {
33
+ await page.goto("/");
34
+ const themeBtn = page
35
+ .locator(
36
+ 'button[aria-label*="theme"], button[aria-label*="Theme"], [data-theme-toggle], button:has(svg.lucide-sun), button:has(svg.lucide-moon)',
37
+ )
38
+ .first();
39
+
40
+ if (await themeBtn.isVisible()) {
41
+ await themeBtn.click();
42
+ await page.waitForTimeout(500);
43
+ const classAfterClick = await page.locator("html").getAttribute("class");
44
+
45
+ await page.reload();
46
+ await page.waitForTimeout(500);
47
+ const classAfterReload = await page.locator("html").getAttribute("class");
48
+
49
+ expect(classAfterReload).toBe(classAfterClick);
50
+ }
51
+ });
52
+});
0
53
▼tests/e2e/webhook-setup.spec.ts
+23-0
1
+import { expect, test } from "@playwright/test";
2
+
3
+test.describe("Webhook Setup", () => {
4
+ test("webhook settings require auth", async ({ page }) => {
5
+ await page.goto("/test/test-repo/settings/webhooks");
6
+ const url = page.url();
7
+ expect(
8
+ url.includes("/login") ||
9
+ page.url().includes("404") ||
10
+ (await page
11
+ .locator("text=not found")
12
+ .or(page.locator("text=denied"))
13
+ .first()
14
+ .isVisible()
15
+ .catch(() => false)),
16
+ ).toBeTruthy();
17
+ });
18
+
19
+ test("webhook API requires auth", async ({ request }) => {
20
+ const res = await request.get("/api/repos/test/test-repo/webhooks");
21
+ expect([401, 403, 404]).toContain(res.status());
22
+ });
23
+});
0
24
▼tests/integration/admin-stats-route.test.ts
+140-0
1
+/**
2
+ * Integration tests for GET /api/admin/stats
3
+ */
4
+import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+/* ─── hoisted mocks ─── */
7
+const mocks = vi.hoisted(() => ({
8
+ selectMock: vi.fn(),
9
+ fakeSchema: {
10
+ repositories: { languages: "languages" },
11
+ users: {},
12
+ pullRequests: { state: "state" },
13
+ commits: { stats: "stats", authorDate: "authorDate" },
14
+ issues: { state: "state" },
15
+ activities: { userId: "userId", createdAt: "createdAt" },
16
+ releases: {},
17
+ },
18
+}));
19
+
20
+vi.mock("@/db", () => ({
21
+ getDatabase: () => mockDb,
22
+ schema: mocks.fakeSchema,
23
+}));
24
+
25
+vi.mock("@/lib/errors", () => ({
26
+ withErrorHandler: (fn: any) => fn,
27
+}));
28
+
29
+vi.mock("drizzle-orm", () => ({
30
+ count: vi.fn(() => "count_expr"),
31
+ desc: vi.fn(),
32
+ eq: vi.fn(),
33
+ gte: vi.fn(),
34
+}));
35
+
36
+/* ─── helpers ─── */
37
+function makeDb() {
38
+ const chainable = {
39
+ from: vi.fn().mockReturnThis(),
40
+ where: vi.fn().mockReturnThis(),
41
+ limit: vi.fn().mockResolvedValue([{ count: 5 }]),
42
+ };
43
+ return {
44
+ select: vi.fn().mockReturnValue(chainable),
45
+ selectDistinct: vi.fn().mockReturnValue({
46
+ from: vi.fn().mockReturnValue({
47
+ where: vi.fn().mockResolvedValue([{ userId: "usr_1" }]),
48
+ }),
49
+ }),
50
+ query: {
51
+ users: {
52
+ findMany: vi
53
+ .fn()
54
+ .mockResolvedValue([
55
+ { username: "alice", createdAt: new Date(), avatarUrl: null },
56
+ ]),
57
+ },
58
+ activities: {
59
+ findMany: vi.fn().mockResolvedValue([
60
+ {
61
+ id: "act_1",
62
+ type: "PUSH",
63
+ action: "pushed",
64
+ targetType: "branch",
65
+ createdAt: new Date(),
66
+ user: { username: "alice" },
67
+ repository: { name: "my-repo" },
68
+ },
69
+ ]),
70
+ },
71
+ },
72
+ };
73
+}
74
+
75
+let mockDb: ReturnType<typeof makeDb>;
76
+const readJson = (r: Response) => r.json();
77
+
78
+function ctx(isAdmin: boolean) {
79
+ return {
80
+ locals: {
81
+ user: isAdmin
82
+ ? { id: "usr_1", username: "admin", isAdmin: true }
83
+ : { id: "usr_2", username: "user", isAdmin: false },
84
+ },
85
+ } as any;
86
+}
87
+
88
+/* ─── import route ─── */
89
+import { GET } from "@/pages/api/admin/stats";
90
+
91
+/* ─── tests ─── */
92
+describe("GET /api/admin/stats", () => {
93
+ beforeEach(() => {
94
+ vi.clearAllMocks();
95
+ mockDb = makeDb();
96
+ });
97
+
98
+ it("returns 200 with stats for admin user", async () => {
99
+ const res = await GET(ctx(true));
100
+ expect(res.status).toBe(200);
101
+ const json = await readJson(res);
102
+ expect(json.totalRepos).toBeDefined();
103
+ expect(json.totalUsers).toBeDefined();
104
+ expect(json.systemStatus).toBeDefined();
105
+ expect(json.systemStatus.cpuLoad).toBeGreaterThanOrEqual(0);
106
+ expect(json.systemStatus.memoryUsage).toBeGreaterThanOrEqual(0);
107
+ });
108
+
109
+ it("returns 403 for non-admin user", async () => {
110
+ const res = await GET(ctx(false));
111
+ expect(res.status).toBe(403);
112
+ });
113
+
114
+ it("returns 403 when no user", async () => {
115
+ const res = await GET({ locals: {} } as any);
116
+ expect(res.status).toBe(403);
117
+ });
118
+
119
+ it("returns quickStats", async () => {
120
+ const res = await GET(ctx(true));
121
+ const json = await readJson(res);
122
+ expect(json.quickStats).toBeDefined();
123
+ expect(typeof json.quickStats.commitsToday).toBe("number");
124
+ expect(typeof json.quickStats.activeUsers).toBe("number");
125
+ });
126
+
127
+ it("returns languages stats", async () => {
128
+ const res = await GET(ctx(true));
129
+ const json = await readJson(res);
130
+ expect(json.languages).toBeDefined();
131
+ expect(Array.isArray(json.languages)).toBe(true);
132
+ });
133
+
134
+ it("returns recentActivity", async () => {
135
+ const res = await GET(ctx(true));
136
+ const json = await readJson(res);
137
+ expect(json.recentActivity).toBeDefined();
138
+ expect(Array.isArray(json.recentActivity)).toBe(true);
139
+ });
140
+});
0
141
▼tests/integration/auth-login-route.test.ts
+197-0
1
+/**
2
+ * Integration tests for POST /api/auth/login
3
+ */
4
+import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+/* ─── hoisted mocks ─── */
7
+const mocks = vi.hoisted(() => ({
8
+ applyRateLimitMock: vi.fn().mockResolvedValue(null),
9
+ verifyPasswordMock: vi.fn().mockResolvedValue(true),
10
+ verify2FATokenMock: vi.fn().mockReturnValue(true),
11
+ createSessionMock: vi.fn().mockResolvedValue({
12
+ id: "sess_1",
13
+ userId: "usr_1",
14
+ token: "sesstoken",
15
+ userAgent: "vitest",
16
+ ipAddress: "127.0.0.1",
17
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
18
+ createdAt: new Date().toISOString(),
19
+ }),
20
+ createTokenMock: vi.fn().mockResolvedValue("jwt_token_xyz"),
21
+ insertMock: vi
22
+ .fn()
23
+ .mockReturnValue({ values: vi.fn().mockResolvedValue([]) }),
24
+ updateMock: vi.fn().mockReturnValue({
25
+ set: vi.fn().mockReturnValue({
26
+ where: vi.fn().mockResolvedValue([]),
27
+ }),
28
+ }),
29
+ fakeSchema: { users: "users", sessions: "sessions" },
30
+}));
31
+
32
+/* ─── module mocks ─── */
33
+vi.mock("@/db", () => ({
34
+ getDatabase: () => mockDb,
35
+ schema: mocks.fakeSchema,
36
+}));
37
+
38
+vi.mock("@/db/schema", () => ({
39
+ users: "users",
40
+ sessions: "sessions",
41
+}));
42
+
43
+vi.mock("@/middleware/rate-limit", () => ({
44
+ applyRateLimit: mocks.applyRateLimitMock,
45
+}));
46
+
47
+vi.mock("@/lib/auth", () => ({
48
+ verifyPassword: mocks.verifyPasswordMock,
49
+ verify2FAToken: mocks.verify2FATokenMock,
50
+ createSession: mocks.createSessionMock,
51
+ createToken: mocks.createTokenMock,
52
+}));
53
+
54
+vi.mock("@/lib/logger", () => ({
55
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
56
+}));
57
+
58
+vi.mock("@/lib/errors", () => ({
59
+ withErrorHandler: (fn: any) => fn,
60
+}));
61
+
62
+vi.mock("drizzle-orm", () => ({
63
+ eq: vi.fn((...a: any[]) => a),
64
+}));
65
+
66
+/* ─── helpers ─── */
67
+function makeDb() {
68
+ return {
69
+ query: {
70
+ users: {
71
+ findFirst: vi.fn().mockResolvedValue({
72
+ id: "usr_1",
73
+ username: "alice",
74
+ email: "[email protected]",
75
+ passwordHash: "hashed_pw",
76
+ isActive: true,
77
+ twoFactorEnabled: false,
78
+ twoFactorSecret: null,
79
+ displayName: "Alice",
80
+ avatarUrl: null,
81
+ isAdmin: false,
82
+ }),
83
+ },
84
+ },
85
+ insert: mocks.insertMock,
86
+ update: mocks.updateMock,
87
+ };
88
+}
89
+
90
+let mockDb: ReturnType<typeof makeDb>;
91
+const readJson = (r: Response) => r.json();
92
+
93
+function makeRequest(body: Record<string, unknown>) {
94
+ return {
95
+ request: new Request("http://localhost/api/auth/login", {
96
+ method: "POST",
97
+ headers: { "Content-Type": "application/json" },
98
+ body: JSON.stringify(body),
99
+ }),
100
+ cookies: { set: vi.fn(), delete: vi.fn() },
101
+ } as any;
102
+}
103
+
104
+/* ─── import route ─── */
105
+import { POST } from "@/pages/api/auth/login";
106
+
107
+/* ─── tests ─── */
108
+describe("POST /api/auth/login", () => {
109
+ beforeEach(() => {
110
+ vi.clearAllMocks();
111
+ mockDb = makeDb();
112
+ });
113
+
114
+ it("returns 200 with token for valid credentials", async () => {
115
+ const res = await POST(
116
+ makeRequest({ login: "alice", password: "pass1234" }),
117
+ );
118
+ expect(res.status).toBe(200);
119
+ const json = await readJson(res);
120
+ expect(json.user.username).toBe("alice");
121
+ expect(json.token).toBe("jwt_token_xyz");
122
+ });
123
+
124
+ it("returns 401 when user not found", async () => {
125
+ mockDb.query.users.findFirst.mockResolvedValue(null);
126
+ const res = await POST(makeRequest({ login: "nobody", password: "pass" }));
127
+ expect(res.status).toBe(401);
128
+ });
129
+
130
+ it("returns 401 when account disabled", async () => {
131
+ mockDb.query.users.findFirst.mockResolvedValue({
132
+ id: "usr_1",
133
+ username: "alice",
134
+ isActive: false,
135
+ passwordHash: "hashed",
136
+ });
137
+ const res = await POST(makeRequest({ login: "alice", password: "pass" }));
138
+ expect(res.status).toBe(401);
139
+ });
140
+
141
+ it("returns 401 when password is wrong", async () => {
142
+ mocks.verifyPasswordMock.mockResolvedValue(false);
143
+ const res = await POST(makeRequest({ login: "alice", password: "wrong" }));
144
+ expect(res.status).toBe(401);
145
+ });
146
+
147
+ it("returns requiresTwoFactor when 2FA enabled but no code", async () => {
148
+ mockDb.query.users.findFirst.mockResolvedValue({
149
+ id: "usr_1",
150
+ username: "alice",
151
+ passwordHash: "hashed",
152
+ isActive: true,
153
+ twoFactorEnabled: true,
154
+ twoFactorSecret: "secret",
155
+ });
156
+ const res = await POST(makeRequest({ login: "alice", password: "pass" }));
157
+ expect(res.status).toBe(200);
158
+ const json = await readJson(res);
159
+ expect(json.requiresTwoFactor).toBe(true);
160
+ });
161
+
162
+ it("returns 401 when 2FA code is invalid", async () => {
163
+ mockDb.query.users.findFirst.mockResolvedValue({
164
+ id: "usr_1",
165
+ username: "alice",
166
+ passwordHash: "hashed",
167
+ isActive: true,
168
+ twoFactorEnabled: true,
169
+ twoFactorSecret: "secret",
170
+ });
171
+ mocks.verify2FATokenMock.mockReturnValue(false);
172
+ const res = await POST(
173
+ makeRequest({ login: "alice", password: "pass", totpCode: "000000" }),
174
+ );
175
+ expect(res.status).toBe(401);
176
+ });
177
+
178
+ it("returns rate limit response when rate limited", async () => {
179
+ mocks.applyRateLimitMock.mockResolvedValue(
180
+ new Response(JSON.stringify({ error: "Rate limited" }), { status: 429 }),
181
+ );
182
+ const res = await POST(makeRequest({ login: "alice", password: "pass" }));
183
+ expect(res.status).toBe(429);
184
+ });
185
+
186
+ it("returns 401 when user has no passwordHash", async () => {
187
+ mockDb.query.users.findFirst.mockResolvedValue({
188
+ id: "usr_1",
189
+ username: "alice",
190
+ passwordHash: null,
191
+ isActive: true,
192
+ twoFactorEnabled: false,
193
+ });
194
+ const res = await POST(makeRequest({ login: "alice", password: "pass" }));
195
+ expect(res.status).toBe(401);
196
+ });
197
+});
0
198
▼tests/integration/auth-me-route.test.ts
+172-0
1
+/**
2
+ * Integration tests for GET / PATCH / DELETE /api/auth/me
3
+ */
4
+import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+/* ─── hoisted mocks ─── */
7
+const mocks = vi.hoisted(() => ({
8
+ getUserFromRequestMock: vi.fn().mockResolvedValue({
9
+ userId: "usr_1",
10
+ username: "alice",
11
+ email: "[email protected]",
12
+ }),
13
+ updateMock: vi.fn().mockReturnValue({
14
+ set: vi.fn().mockReturnValue({
15
+ where: vi.fn().mockResolvedValue([]),
16
+ }),
17
+ }),
18
+ deleteMock: vi.fn().mockReturnValue({
19
+ where: vi.fn().mockResolvedValue([]),
20
+ }),
21
+ logAuditMock: vi.fn().mockResolvedValue(undefined),
22
+ getRequestMetaMock: vi
23
+ .fn()
24
+ .mockReturnValue({ ip: "127.0.0.1", userAgent: "vitest" }),
25
+ fakeSchema: { users: "users" },
26
+}));
27
+
28
+/* ─── module mocks ─── */
29
+vi.mock("@/db", () => ({
30
+ getDatabase: () => mockDb,
31
+ schema: mocks.fakeSchema,
32
+}));
33
+
34
+vi.mock("@/db/schema", () => ({
35
+ users: "users",
36
+}));
37
+
38
+vi.mock("@/lib/auth", () => ({
39
+ getUserFromRequest: mocks.getUserFromRequestMock,
40
+}));
41
+
42
+vi.mock("@/lib/audit", () => ({
43
+ logAudit: mocks.logAuditMock,
44
+ getRequestMeta: mocks.getRequestMetaMock,
45
+}));
46
+
47
+vi.mock("@/lib/logger", () => ({
48
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
49
+}));
50
+
51
+vi.mock("@/lib/errors", () => ({
52
+ withErrorHandler: (fn: any) => fn,
53
+}));
54
+
55
+vi.mock("drizzle-orm", () => ({
56
+ eq: vi.fn((...a: any[]) => a),
57
+}));
58
+
59
+/* ─── helpers ─── */
60
+const fakeUser = {
61
+ id: "usr_1",
62
+ username: "alice",
63
+ email: "[email protected]",
64
+ displayName: "Alice",
65
+ bio: "Developer",
66
+ avatarUrl: null,
67
+ location: "NYC",
68
+ website: "",
69
+ company: "ACME",
70
+ isAdmin: false,
71
+ emailVerified: true,
72
+ twoFactorEnabled: false,
73
+ createdAt: new Date(),
74
+};
75
+
76
+function makeDb() {
77
+ return {
78
+ query: {
79
+ users: {
80
+ findFirst: vi.fn().mockResolvedValue(fakeUser),
81
+ },
82
+ },
83
+ update: mocks.updateMock,
84
+ delete: mocks.deleteMock,
85
+ };
86
+}
87
+
88
+let mockDb: ReturnType<typeof makeDb>;
89
+const readJson = (r: Response) => r.json();
90
+
91
+function ctx(method: string, body?: Record<string, unknown>) {
92
+ const init: RequestInit = {
93
+ method,
94
+ headers: { "Content-Type": "application/json" },
95
+ };
96
+ if (body) init.body = JSON.stringify(body);
97
+ return {
98
+ request: new Request("http://localhost/api/auth/me", init),
99
+ cookies: { set: vi.fn(), delete: vi.fn() },
100
+ } as any;
101
+}
102
+
103
+/* ─── import routes ─── */
104
+import { DELETE, GET, PATCH } from "@/pages/api/auth/me";
105
+
106
+/* ─── tests ─── */
107
+describe("GET /api/auth/me", () => {
108
+ beforeEach(() => {
109
+ vi.clearAllMocks();
110
+ mockDb = makeDb();
111
+ });
112
+
113
+ it("returns 200 with user profile", async () => {
114
+ const res = await GET(ctx("GET"));
115
+ expect(res.status).toBe(200);
116
+ const json = await readJson(res);
117
+ expect(json.username).toBe("alice");
118
+ expect(json.email).toBe("[email protected]");
119
+ });
120
+
121
+ it("returns 401 when not authenticated", async () => {
122
+ mocks.getUserFromRequestMock.mockResolvedValue(null);
123
+ const res = await GET(ctx("GET"));
124
+ expect(res.status).toBe(401);
125
+ });
126
+
127
+ it("returns 404 when user no longer exists", async () => {
128
+ mockDb.query.users.findFirst.mockResolvedValue(null);
129
+ const res = await GET(ctx("GET"));
130
+ expect(res.status).toBe(404);
131
+ });
132
+});
133
+
134
+describe("PATCH /api/auth/me", () => {
135
+ beforeEach(() => {
136
+ vi.clearAllMocks();
137
+ mockDb = makeDb();
138
+ });
139
+
140
+ it("returns 200 when profile updated", async () => {
141
+ const res = await PATCH(ctx("PATCH", { displayName: "Alice W" }));
142
+ expect(res.status).toBe(200);
143
+ const json = await readJson(res);
144
+ expect(json.message).toContain("updated");
145
+ });
146
+
147
+ it("returns 401 when not authenticated", async () => {
148
+ mocks.getUserFromRequestMock.mockResolvedValue(null);
149
+ const res = await PATCH(ctx("PATCH", { displayName: "x" }));
150
+ expect(res.status).toBe(401);
151
+ });
152
+});
153
+
154
+describe("DELETE /api/auth/me", () => {
155
+ beforeEach(() => {
156
+ vi.clearAllMocks();
157
+ mockDb = makeDb();
158
+ });
159
+
160
+ it("returns 200 and deletes account", async () => {
161
+ const res = await DELETE(ctx("DELETE"));
162
+ expect(res.status).toBe(200);
163
+ const json = await readJson(res);
164
+ expect(json.message).toContain("deleted");
165
+ });
166
+
167
+ it("returns 401 when not authenticated", async () => {
168
+ mocks.getUserFromRequestMock.mockResolvedValue(null);
169
+ const res = await DELETE(ctx("DELETE"));
170
+ expect(res.status).toBe(401);
171
+ });
172
+});
0
173
▼tests/integration/auth-register-route.test.ts
+208-0
1
+/**
2
+ * Integration tests for POST /api/auth/register
3
+ */
4
+import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+/* ─── hoisted mocks ─── */
7
+const mocks = vi.hoisted(() => ({
8
+ applyRateLimitMock: vi.fn().mockResolvedValue(null),
9
+ hashPasswordMock: vi.fn().mockResolvedValue("hashed_pw_123"),
10
+ createSessionMock: vi.fn().mockResolvedValue({
11
+ id: "sess_1",
12
+ userId: "usr_new",
13
+ token: "sesstoken",
14
+ userAgent: "vitest",
15
+ ipAddress: null,
16
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
17
+ createdAt: new Date().toISOString(),
18
+ }),
19
+ createTokenMock: vi.fn().mockResolvedValue("jwt_register_token"),
20
+ generateIdMock: vi.fn().mockReturnValue("usr_new"),
21
+ isValidUsernameMock: vi.fn().mockReturnValue(true),
22
+ isValidEmailMock: vi.fn().mockReturnValue(true),
23
+ insertMock: vi
24
+ .fn()
25
+ .mockReturnValue({ values: vi.fn().mockResolvedValue([]) }),
26
+ fakeSchema: { users: "users", sessions: "sessions" },
27
+}));
28
+
29
+/* ─── module mocks ─── */
30
+vi.mock("@/db", () => ({
31
+ getDatabase: () => mockDb,
32
+ schema: mocks.fakeSchema,
33
+}));
34
+
35
+vi.mock("@/db/schema", () => ({
36
+ users: "users",
37
+ sessions: "sessions",
38
+}));
39
+
40
+vi.mock("@/middleware/rate-limit", () => ({
41
+ applyRateLimit: mocks.applyRateLimitMock,
42
+}));
43
+
44
+vi.mock("@/lib/auth", () => ({
45
+ hashPassword: mocks.hashPasswordMock,
46
+ createSession: mocks.createSessionMock,
47
+ createToken: mocks.createTokenMock,
48
+}));
49
+
50
+vi.mock("@/lib/utils", () => ({
51
+ generateId: mocks.generateIdMock,
52
+ isValidUsername: mocks.isValidUsernameMock,
53
+ isValidEmail: mocks.isValidEmailMock,
54
+ now: () => new Date(),
55
+}));
56
+
57
+vi.mock("@/lib/validation", () => ({
58
+ RegisterUserSchema: {
59
+ safeParse: vi.fn().mockReturnValue({ success: true }),
60
+ },
61
+}));
62
+
63
+vi.mock("@/lib/logger", () => ({
64
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
65
+}));
66
+
67
+vi.mock("@/lib/errors", () => ({
68
+ withErrorHandler: (fn: any) => fn,
69
+}));
70
+
71
+vi.mock("drizzle-orm", () => ({
72
+ eq: vi.fn((...a: any[]) => a),
73
+}));
74
+
75
+/* ─── helpers ─── */
76
+function makeDb() {
77
+ return {
78
+ query: {
79
+ users: {
80
+ findFirst: vi.fn().mockResolvedValue(null), // no existing user
81
+ },
82
+ },
83
+ insert: mocks.insertMock,
84
+ };
85
+}
86
+
87
+let mockDb: ReturnType<typeof makeDb>;
88
+const readJson = (r: Response) => r.json();
89
+
90
+function makeRequest(body: Record<string, unknown>) {
91
+ return {
92
+ request: new Request("http://localhost/api/auth/register", {
93
+ method: "POST",
94
+ headers: { "Content-Type": "application/json" },
95
+ body: JSON.stringify(body),
96
+ }),
97
+ cookies: { set: vi.fn(), delete: vi.fn() },
98
+ } as any;
99
+}
100
+
101
+/* ─── import route ─── */
102
+import { POST } from "@/pages/api/auth/register";
103
+
104
+/* ─── tests ─── */
105
+describe("POST /api/auth/register", () => {
106
+ beforeEach(() => {
107
+ vi.clearAllMocks();
108
+ mockDb = makeDb();
109
+ delete process.env.ENABLE_REGISTRATION;
110
+ });
111
+
112
+ it("returns 201 with token for valid registration", async () => {
113
+ const res = await POST(
114
+ makeRequest({
115
+ username: "newuser",
116
+ email: "[email protected]",
117
+ password: "password123",
118
+ displayName: "New User",
119
+ }),
120
+ );
121
+ expect(res.status).toBe(201);
122
+ const json = await readJson(res);
123
+ expect(json.user.username).toBe("newuser");
124
+ expect(json.token).toBe("jwt_register_token");
125
+ });
126
+
127
+ it("returns 400 when registration is disabled", async () => {
128
+ process.env.ENABLE_REGISTRATION = "false";
129
+ const res = await POST(
130
+ makeRequest({
131
+ username: "newuser",
132
+ email: "[email protected]",
133
+ password: "password123",
134
+ }),
135
+ );
136
+ expect(res.status).toBe(400);
137
+ });
138
+
139
+ it("returns 409 when username already taken", async () => {
140
+ mockDb.query.users.findFirst.mockResolvedValue({
141
+ id: "usr_1",
142
+ username: "newuser",
143
+ email: "[email protected]",
144
+ });
145
+ const res = await POST(
146
+ makeRequest({
147
+ username: "newuser",
148
+ email: "[email protected]",
149
+ password: "password123",
150
+ }),
151
+ );
152
+ expect(res.status).toBe(409);
153
+ });
154
+
155
+ it("returns 409 when email already registered", async () => {
156
+ mockDb.query.users.findFirst.mockResolvedValue({
157
+ id: "usr_1",
158
+ username: "other",
159
+ email: "[email protected]",
160
+ });
161
+ const res = await POST(
162
+ makeRequest({
163
+ username: "newuser",
164
+ email: "[email protected]",
165
+ password: "password123",
166
+ }),
167
+ );
168
+ expect(res.status).toBe(409);
169
+ });
170
+
171
+ it("returns 400 when username format invalid", async () => {
172
+ mocks.isValidUsernameMock.mockReturnValue(false);
173
+ const res = await POST(
174
+ makeRequest({
175
+ username: "bad user!",
176
+ email: "[email protected]",
177
+ password: "password123",
178
+ }),
179
+ );
180
+ expect(res.status).toBe(400);
181
+ });
182
+
183
+ it("returns 400 when email format invalid", async () => {
184
+ mocks.isValidEmailMock.mockReturnValue(false);
185
+ const res = await POST(
186
+ makeRequest({
187
+ username: "newuser",
188
+ email: "not-an-email",
189
+ password: "password123",
190
+ }),
191
+ );
192
+ expect(res.status).toBe(400);
193
+ });
194
+
195
+ it("returns rate limit response when rate limited", async () => {
196
+ mocks.applyRateLimitMock.mockResolvedValue(
197
+ new Response(JSON.stringify({ error: "Rate limited" }), { status: 429 }),
198
+ );
199
+ const res = await POST(
200
+ makeRequest({
201
+ username: "newuser",
202
+ email: "[email protected]",
203
+ password: "password123",
204
+ }),
205
+ );
206
+ expect(res.status).toBe(429);
207
+ });
208
+});
0
209
▼tests/integration/issues-releases-route.test.ts
+322-0
1
+/**
2
+ * Integration tests for POST /api/repos/[owner]/[repo]/issues and GET/POST /api/repos/[owner]/[repo]/releases
3
+ */
4
+import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+/* ─── hoisted mocks ─── */
7
+const mocks = vi.hoisted(() => ({
8
+ getUserFromRequestMock: vi.fn().mockResolvedValue({
9
+ userId: "usr_1",
10
+ username: "alice",
11
+ email: "[email protected]",
12
+ }),
13
+ canReadRepoMock: vi.fn().mockResolvedValue(true),
14
+ canWriteRepoMock: vi.fn().mockResolvedValue(true),
15
+ generateIdMock: vi.fn().mockReturnValue("issue_new"),
16
+ logActivityMock: vi.fn().mockResolvedValue(undefined),
17
+ autoLinkCrossRepoIssuesMock: vi.fn().mockResolvedValue(undefined),
18
+ insertMock: vi
19
+ .fn()
20
+ .mockReturnValue({ values: vi.fn().mockResolvedValue([]) }),
21
+ updateMock: vi.fn().mockReturnValue({
22
+ set: vi.fn().mockReturnValue({
23
+ where: vi.fn().mockResolvedValue([]),
24
+ }),
25
+ }),
26
+ selectMock: vi.fn(),
27
+ fakeSchema: {
28
+ users: { username: "username" },
29
+ repositories: {
30
+ ownerId: "ownerId",
31
+ name: "name",
32
+ openIssueCount: "openIssueCount",
33
+ id: "id",
34
+ },
35
+ issues: { repositoryId: "repositoryId", number: "number" },
36
+ releases: {
37
+ repositoryId: "repositoryId",
38
+ id: "id",
39
+ createdAt: "createdAt",
40
+ },
41
+ tags: { repositoryId: "repositoryId", name: "name" },
42
+ },
43
+}));
44
+
45
+vi.mock("@/db", () => ({
46
+ getDatabase: () => mockDb,
47
+ schema: mocks.fakeSchema,
48
+}));
49
+
50
+vi.mock("@/db/schema", () => ({
51
+ issues: mocks.fakeSchema.issues,
52
+ repositories: mocks.fakeSchema.repositories,
53
+}));
54
+
55
+vi.mock("@/lib/auth", () => ({
56
+ getUserFromRequest: mocks.getUserFromRequestMock,
57
+}));
58
+
59
+vi.mock("@/lib/permissions", () => ({
60
+ canReadRepo: mocks.canReadRepoMock,
61
+ canWriteRepo: mocks.canWriteRepoMock,
62
+}));
63
+
64
+vi.mock("@/lib/utils", () => ({
65
+ generateId: mocks.generateIdMock,
66
+ now: () => new Date(),
67
+}));
68
+
69
+vi.mock("@/lib/activity", () => ({
70
+ logActivity: mocks.logActivityMock,
71
+}));
72
+
73
+vi.mock("@/lib/cross-repo-issues", () => ({
74
+ autoLinkCrossRepoIssues: mocks.autoLinkCrossRepoIssuesMock,
75
+}));
76
+
77
+vi.mock("@/lib/errors", () => ({
78
+ withErrorHandler: (fn: any) => fn,
79
+}));
80
+
81
+vi.mock("@/lib/logger", () => ({
82
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
83
+}));
84
+
85
+vi.mock("@/lib/email", () => ({
86
+ sendIssueEmail: vi.fn().mockResolvedValue(undefined),
87
+}));
88
+
89
+vi.mock("drizzle-orm", () => ({
90
+ eq: vi.fn((...a: any[]) => a),
91
+ and: vi.fn((...a: any[]) => a),
92
+ desc: vi.fn(),
93
+ sql: vi.fn(),
94
+}));
95
+
96
+/* ─── helpers ─── */
97
+const fakeOwner = {
98
+ id: "usr_owner",
99
+ username: "owner",
100
+ email: "[email protected]",
101
+};
102
+const fakeRepo = {
103
+ id: "repo_1",
104
+ name: "my-repo",
105
+ ownerId: "usr_owner",
106
+ isPrivate: false,
107
+ openIssueCount: 3,
108
+};
109
+
110
+function makeDb() {
111
+ return {
112
+ query: {
113
+ users: { findFirst: vi.fn().mockResolvedValue(fakeOwner) },
114
+ repositories: { findFirst: vi.fn().mockResolvedValue(fakeRepo) },
115
+ issues: {
116
+ findFirst: vi.fn().mockResolvedValue({ id: "issue_1", number: 5 }),
117
+ },
118
+ releases: {
119
+ findFirst: vi.fn().mockResolvedValue({
120
+ id: "rel_1",
121
+ name: "v1.0",
122
+ repositoryId: "repo_1",
123
+ isDraft: false,
124
+ }),
125
+ },
126
+ tags: {
127
+ findFirst: vi.fn().mockResolvedValue({ id: "tag_1", name: "v1.0" }),
128
+ },
129
+ },
130
+ insert: mocks.insertMock,
131
+ update: mocks.updateMock,
132
+ select: mocks.selectMock.mockReturnValue({
133
+ from: vi.fn().mockReturnValue({
134
+ where: vi.fn().mockReturnValue({
135
+ orderBy: vi.fn().mockResolvedValue([
136
+ { id: "rel_1", name: "v1.0", isDraft: false },
137
+ { id: "rel_2", name: "v1.1-beta", isDraft: true },
138
+ ]),
139
+ }),
140
+ }),
141
+ }),
142
+ };
143
+}
144
+
145
+let mockDb: ReturnType<typeof makeDb>;
146
+const readJson = (r: Response) => r.json();
147
+
148
+/* ─── import routes ─── */
149
+import { POST as createIssue } from "@/pages/api/repos/[owner]/[repo]/issues/index";
150
+import {
151
+ POST as createRelease,
152
+ GET as listReleases,
153
+} from "@/pages/api/repos/[owner]/[repo]/releases/index";
154
+
155
+/* ─── Issue tests ─── */
156
+describe("POST /api/repos/[owner]/[repo]/issues", () => {
157
+ beforeEach(() => {
158
+ vi.clearAllMocks();
159
+ mockDb = makeDb();
160
+ });
161
+
162
+ it("returns 201 when issue created", async () => {
163
+ const res = await createIssue({
164
+ request: new Request("http://localhost/api/repos/owner/my-repo/issues", {
165
+ method: "POST",
166
+ headers: { "Content-Type": "application/json" },
167
+ body: JSON.stringify({
168
+ title: "Bug report",
169
+ body: "Something is broken",
170
+ }),
171
+ }),
172
+ params: { owner: "owner", repo: "my-repo" },
173
+ } as any);
174
+ expect(res.status).toBe(201);
175
+ const json = await readJson(res);
176
+ expect(json.title).toBe("Bug report");
177
+ expect(json.number).toBe(6);
178
+ });
179
+
180
+ it("returns 401 when not authenticated", async () => {
181
+ mocks.getUserFromRequestMock.mockResolvedValue(null);
182
+ const res = await createIssue({
183
+ request: new Request("http://localhost/api/repos/owner/my-repo/issues", {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/json" },
186
+ body: JSON.stringify({ title: "Bug" }),
187
+ }),
188
+ params: { owner: "owner", repo: "my-repo" },
189
+ } as any);
190
+ expect(res.status).toBe(401);
191
+ });
192
+
193
+ it("returns 404 when owner not found", async () => {
194
+ mockDb.query.users.findFirst.mockResolvedValue(null);
195
+ const res = await createIssue({
196
+ request: new Request("http://localhost/api/repos/no-one/my-repo/issues", {
197
+ method: "POST",
198
+ headers: { "Content-Type": "application/json" },
199
+ body: JSON.stringify({ title: "Bug" }),
200
+ }),
201
+ params: { owner: "no-one", repo: "my-repo" },
202
+ } as any);
203
+ expect(res.status).toBe(404);
204
+ });
205
+
206
+ it("returns 404 when repo not found", async () => {
207
+ mockDb.query.repositories.findFirst.mockResolvedValue(null);
208
+ const res = await createIssue({
209
+ request: new Request("http://localhost/api/repos/owner/no-repo/issues", {
210
+ method: "POST",
211
+ headers: { "Content-Type": "application/json" },
212
+ body: JSON.stringify({ title: "Bug" }),
213
+ }),
214
+ params: { owner: "owner", repo: "no-repo" },
215
+ } as any);
216
+ expect(res.status).toBe(404);
217
+ });
218
+
219
+ it("returns 404 when user has no read permission", async () => {
220
+ mocks.canReadRepoMock.mockResolvedValue(false);
221
+ const res = await createIssue({
222
+ request: new Request("http://localhost/api/repos/owner/my-repo/issues", {
223
+ method: "POST",
224
+ headers: { "Content-Type": "application/json" },
225
+ body: JSON.stringify({ title: "Bug" }),
226
+ }),
227
+ params: { owner: "owner", repo: "my-repo" },
228
+ } as any);
229
+ expect(res.status).toBe(404);
230
+ });
231
+});
232
+
233
+/* ─── Release tests ─── */
234
+describe("GET /api/repos/[owner]/[repo]/releases", () => {
235
+ beforeEach(() => {
236
+ vi.clearAllMocks();
237
+ mockDb = makeDb();
238
+ });
239
+
240
+ it("returns 200 with releases list", async () => {
241
+ const res = await listReleases({
242
+ params: { owner: "owner", repo: "my-repo" },
243
+ request: new Request("http://localhost/api/repos/owner/my-repo/releases"),
244
+ } as any);
245
+ expect(res.status).toBe(200);
246
+ });
247
+
248
+ it("filters drafts for non-writers", async () => {
249
+ mocks.canWriteRepoMock.mockResolvedValue(false);
250
+ const res = await listReleases({
251
+ params: { owner: "owner", repo: "my-repo" },
252
+ request: new Request("http://localhost/api/repos/owner/my-repo/releases"),
253
+ } as any);
254
+ expect(res.status).toBe(200);
255
+ const json = await readJson(res);
256
+ const drafts = json.filter((r: any) => r.isDraft);
257
+ expect(drafts.length).toBe(0);
258
+ });
259
+
260
+ it("returns 404 when repo not found", async () => {
261
+ mockDb.query.repositories.findFirst.mockResolvedValue(null);
262
+ const res = await listReleases({
263
+ params: { owner: "owner", repo: "no-repo" },
264
+ request: new Request("http://localhost/api/repos/owner/no-repo/releases"),
265
+ } as any);
266
+ expect(res.status).toBe(404);
267
+ });
268
+});
269
+
270
+describe("POST /api/repos/[owner]/[repo]/releases", () => {
271
+ beforeEach(() => {
272
+ vi.clearAllMocks();
273
+ mockDb = makeDb();
274
+ });
275
+
276
+ it("returns 200 when release created", async () => {
277
+ const res = await createRelease({
278
+ params: { owner: "owner", repo: "my-repo" },
279
+ request: new Request(
280
+ "http://localhost/api/repos/owner/my-repo/releases",
281
+ {
282
+ method: "POST",
283
+ headers: { "Content-Type": "application/json" },
284
+ body: JSON.stringify({ tagName: "v1.0", name: "Release 1.0" }),
285
+ },
286
+ ),
287
+ } as any);
288
+ expect(res.status).toBe(200);
289
+ });
290
+
291
+ it("returns 401 when not authenticated", async () => {
292
+ mocks.getUserFromRequestMock.mockResolvedValue(null);
293
+ const res = await createRelease({
294
+ params: { owner: "owner", repo: "my-repo" },
295
+ request: new Request(
296
+ "http://localhost/api/repos/owner/my-repo/releases",
297
+ {
298
+ method: "POST",
299
+ headers: { "Content-Type": "application/json" },
300
+ body: JSON.stringify({ tagName: "v1.0", name: "Release 1.0" }),
301
+ },
302
+ ),
303
+ } as any);
304
+ expect(res.status).toBe(401);
305
+ });
306
+
307
+ it("returns 403 when user cannot write", async () => {
308
+ mocks.canWriteRepoMock.mockResolvedValue(false);
309
+ const res = await createRelease({
310
+ params: { owner: "owner", repo: "my-repo" },
311
+ request: new Request(
312
+ "http://localhost/api/repos/owner/my-repo/releases",
313
+ {
314
+ method: "POST",
315
+ headers: { "Content-Type": "application/json" },
316
+ body: JSON.stringify({ tagName: "v1.0", name: "Release 1.0" }),
317
+ },
318
+ ),
319
+ } as any);
320
+ expect(res.status).toBe(403);
321
+ });
322
+});
0
323
▼tests/integration/notifications-route.test.ts
+177-0
1
+/**
2
+ * Integration tests for GET /api/notifications
3
+ */
4
+import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+/* ─── hoisted mocks ─── */
7
+const mocks = vi.hoisted(() => ({
8
+ getUserFromRequestMock: vi.fn().mockResolvedValue({
9
+ userId: "usr_1",
10
+ username: "alice",
11
+ }),
12
+ scoreNotificationPriorityMock: vi.fn().mockReturnValue({
13
+ score: 5,
14
+ priority: "medium",
15
+ isBlocking: false,
16
+ }),
17
+ getRoutingDecisionMock: vi.fn().mockReturnValue({
18
+ channels: ["in_app"],
19
+ primaryChannel: "in_app",
20
+ }),
21
+ channelEnabledMock: vi.fn().mockReturnValue(true),
22
+ computePersonalizationBoostMock: vi.fn().mockReturnValue(0),
23
+ fakeSchema: {
24
+ notifications: "notifications",
25
+ notificationPreferences: {
26
+ userId: "userId",
27
+ repositoryId: "repositoryId",
28
+ },
29
+ },
30
+}));
31
+
32
+vi.mock("@/db", () => ({
33
+ getDatabase: () => mockDb,
34
+ schema: mocks.fakeSchema,
35
+}));
36
+
37
+vi.mock("@/db/schema", () => ({
38
+ notifications: "notifications",
39
+}));
40
+
41
+vi.mock("@/lib/auth", () => ({
42
+ getUserFromRequest: mocks.getUserFromRequestMock,
43
+}));
44
+
45
+vi.mock("@/lib/notification-priority", () => ({
46
+ scoreNotificationPriority: mocks.scoreNotificationPriorityMock,
47
+}));
48
+
49
+vi.mock("@/lib/notification-routing", () => ({
50
+ getRoutingDecision: mocks.getRoutingDecisionMock,
51
+ channelEnabled: mocks.channelEnabledMock,
52
+ computePersonalizationBoost: mocks.computePersonalizationBoostMock,
53
+}));
54
+
55
+vi.mock("drizzle-orm", () => ({
56
+ eq: vi.fn(),
57
+ desc: vi.fn(),
58
+ and: vi.fn((...args: any[]) => args),
59
+ or: vi.fn((...args: any[]) => args),
60
+ isNull: vi.fn(),
61
+}));
62
+
63
+/* ─── helpers ─── */
64
+const fakeNotification = {
65
+ id: "notif_1",
66
+ userId: "usr_1",
67
+ type: "comment",
68
+ reason: "mention",
69
+ isRead: false,
70
+ isArchived: false,
71
+ createdAt: new Date().toISOString(),
72
+ actor: { id: "usr_2", username: "bob", displayName: "Bob", avatarUrl: null },
73
+ repository: { id: "repo_1", name: "my-repo", slug: "my-repo" },
74
+};
75
+
76
+function makeDb() {
77
+ return {
78
+ query: {
79
+ notifications: {
80
+ findMany: vi.fn().mockResolvedValue([fakeNotification]),
81
+ },
82
+ notificationPreferences: {
83
+ findMany: vi.fn().mockResolvedValue([]),
84
+ },
85
+ },
86
+ };
87
+}
88
+
89
+let mockDb: ReturnType<typeof makeDb>;
90
+const readJson = (r: Response) => r.json();
91
+
92
+function ctx(params = "") {
93
+ const reqUrl = `http://localhost/api/notifications${params}`;
94
+ return {
95
+ request: new Request(reqUrl),
96
+ url: new URL(reqUrl),
97
+ } as any;
98
+}
99
+
100
+/* ─── import route ─── */
101
+import { GET } from "@/pages/api/notifications/index";
102
+
103
+/* ─── tests ─── */
104
+describe("GET /api/notifications", () => {
105
+ beforeEach(() => {
106
+ vi.clearAllMocks();
107
+ mockDb = makeDb();
108
+ });
109
+
110
+ it("returns 200 with notifications for authenticated user", async () => {
111
+ const res = await GET(ctx());
112
+ expect(res.status).toBe(200);
113
+ const json = await readJson(res);
114
+ expect(json.notifications).toBeDefined();
115
+ expect(json.notifications.length).toBe(1);
116
+ expect(json.unreadCount).toBeDefined();
117
+ });
118
+
119
+ it("returns 401 when not authenticated", async () => {
120
+ mocks.getUserFromRequestMock.mockResolvedValue(null);
121
+ const res = await GET(ctx());
122
+ expect(res.status).toBe(401);
123
+ });
124
+
125
+ it("enriches notifications with priority", async () => {
126
+ const res = await GET(ctx());
127
+ const json = await readJson(res);
128
+ expect(json.notifications[0].priority).toBe("medium");
129
+ expect(json.notifications[0].priorityScore).toBeGreaterThanOrEqual(0);
130
+ });
131
+
132
+ it("enriches with routing info", async () => {
133
+ const res = await GET(ctx());
134
+ const json = await readJson(res);
135
+ expect(json.notifications[0].routeChannels).toEqual(["in_app"]);
136
+ expect(json.notifications[0].primaryRouteChannel).toBe("in_app");
137
+ });
138
+
139
+ it("filters by channel when param provided", async () => {
140
+ mocks.channelEnabledMock.mockReturnValue(false);
141
+ const res = await GET(ctx("?channel=email"));
142
+ const json = await readJson(res);
143
+ expect(json.notifications.length).toBe(0);
144
+ });
145
+
146
+ it("supports prioritize param", async () => {
147
+ // add two notifs with different scores
148
+ const high = { ...fakeNotification, id: "notif_h" };
149
+ const low = { ...fakeNotification, id: "notif_l" };
150
+ mockDb.query.notifications.findMany
151
+ .mockResolvedValueOnce([low, high])
152
+ .mockResolvedValueOnce([low, high]) // history
153
+ .mockResolvedValueOnce([low, high]); // unread count
154
+
155
+ mocks.scoreNotificationPriorityMock
156
+ .mockReturnValueOnce({ score: 1, priority: "low", isBlocking: false })
157
+ .mockReturnValueOnce({ score: 10, priority: "high", isBlocking: true });
158
+
159
+ const res = await GET(ctx("?prioritize=true"));
160
+ expect(res.status).toBe(200);
161
+ });
162
+
163
+ it("returns routing metadata", async () => {
164
+ const res = await GET(ctx("?personalize=true"));
165
+ const json = await readJson(res);
166
+ expect(json.routing.personalized).toBe(true);
167
+ expect(json.routing.channelFilter).toBeNull();
168
+ });
169
+
170
+ it("handles empty notifications", async () => {
171
+ mockDb.query.notifications.findMany.mockResolvedValue([]);
172
+ const res = await GET(ctx());
173
+ const json = await readJson(res);
174
+ expect(json.notifications).toEqual([]);
175
+ expect(json.unreadCount).toBe(0);
176
+ });
177
+});
0
178
▼tests/integration/org-members-route.test.ts
+121-0
1
+/**
2
+ * Integration tests for GET /api/orgs/[org]/members
3
+ */
4
+import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+/* ─── hoisted mocks ─── */
7
+const mocks = vi.hoisted(() => ({
8
+ canAdminOrgMock: vi.fn().mockResolvedValue(true),
9
+ fakeSchema: {
10
+ organizations: { name: "name" },
11
+ organizationMembers: { organizationId: "organizationId" },
12
+ },
13
+}));
14
+
15
+vi.mock("@/db", () => ({
16
+ getDatabase: () => mockDb,
17
+ schema: mocks.fakeSchema,
18
+}));
19
+
20
+vi.mock("@/lib/permissions", () => ({
21
+ canAdminOrg: mocks.canAdminOrgMock,
22
+}));
23
+
24
+vi.mock("@/lib/errors", () => ({
25
+ withErrorHandler: (fn: any) => fn,
26
+}));
27
+
28
+vi.mock("drizzle-orm", () => ({
29
+ eq: vi.fn((...a: any[]) => a),
30
+ and: vi.fn((...a: any[]) => a),
31
+}));
32
+
33
+/* ─── helpers ─── */
34
+const fakeOrg = { id: "org_1", name: "acme" };
35
+const fakeMembers = [
36
+ {
37
+ id: "mem_1",
38
+ userId: "usr_1",
39
+ role: "admin",
40
+ user: { id: "usr_1", username: "alice" },
41
+ },
42
+ {
43
+ id: "mem_2",
44
+ userId: "usr_2",
45
+ role: "member",
46
+ user: { id: "usr_2", username: "bob" },
47
+ },
48
+];
49
+
50
+function makeDb() {
51
+ return {
52
+ query: {
53
+ organizations: {
54
+ findFirst: vi.fn().mockResolvedValue(fakeOrg),
55
+ },
56
+ organizationMembers: {
57
+ findMany: vi.fn().mockResolvedValue(fakeMembers),
58
+ },
59
+ },
60
+ };
61
+}
62
+
63
+let mockDb: ReturnType<typeof makeDb>;
64
+const readJson = (r: Response) => r.json();
65
+
66
+function ctx(org: string, user?: { id: string; isAdmin: boolean }) {
67
+ return {
68
+ params: { org },
69
+ locals: {
70
+ user: user || { id: "usr_1", isAdmin: false },
71
+ },
72
+ } as any;
73
+}
74
+
75
+/* ─── import route ─── */
76
+import { GET } from "@/pages/api/orgs/[org]/members";
77
+
78
+/* ─── tests ─── */
79
+describe("GET /api/orgs/[org]/members", () => {
80
+ beforeEach(() => {
81
+ vi.clearAllMocks();
82
+ mockDb = makeDb();
83
+ });
84
+
85
+ it("returns 200 with members list", async () => {
86
+ const res = await GET(ctx("acme"));
87
+ expect(res.status).toBe(200);
88
+ const json = await readJson(res);
89
+ expect(json.members).toHaveLength(2);
90
+ expect(json.members[0].user.username).toBe("alice");
91
+ });
92
+
93
+ it("returns 401 when not authenticated", async () => {
94
+ const res = await GET({ params: { org: "acme" }, locals: {} } as any);
95
+ expect(res.status).toBe(401);
96
+ });
97
+
98
+ it("returns 400 when org param missing", async () => {
99
+ const res = await GET(ctx(undefined as any));
100
+ expect(res.status).toBe(400);
101
+ });
102
+
103
+ it("returns 404 when organization not found", async () => {
104
+ mockDb.query.organizations.findFirst.mockResolvedValue(null);
105
+ const res = await GET(ctx("nonexistent"));
106
+ expect(res.status).toBe(404);
107
+ });
108
+
109
+ it("returns 403 when user cannot admin org", async () => {
110
+ mocks.canAdminOrgMock.mockResolvedValue(false);
111
+ const res = await GET(ctx("acme"));
112
+ expect(res.status).toBe(403);
113
+ });
114
+
115
+ it("handles empty members list", async () => {
116
+ mockDb.query.organizationMembers.findMany.mockResolvedValue([]);
117
+ const res = await GET(ctx("acme"));
118
+ const json = await readJson(res);
119
+ expect(json.members).toEqual([]);
120
+ });
121
+});
0
122
▼tests/integration/search-route.test.ts
+178-0
1
+/**
2
+ * Integration tests for GET /api/search
3
+ */
4
+import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+/* ─── hoisted mocks ─── */
7
+const mocks = vi.hoisted(() => ({
8
+ fakeSchema: {
9
+ repositories: {
10
+ name: "name",
11
+ description: "description",
12
+ starCount: "starCount",
13
+ },
14
+ users: { username: "username", displayName: "displayName" },
15
+ issues: { title: "title", body: "body" },
16
+ pullRequests: { title: "title", body: "body" },
17
+ workflows: { name: "name" },
18
+ },
19
+}));
20
+
21
+vi.mock("@/db", () => ({
22
+ getDatabase: () => mockDb,
23
+ schema: mocks.fakeSchema,
24
+}));
25
+
26
+vi.mock("@/lib/errors", () => ({
27
+ withErrorHandler: (fn: any) => fn,
28
+}));
29
+
30
+vi.mock("drizzle-orm", () => ({
31
+ like: vi.fn(),
32
+ or: vi.fn((...args: any[]) => args),
33
+ eq: vi.fn(),
34
+ desc: vi.fn(),
35
+}));
36
+
37
+/* ─── helpers ─── */
38
+function makeDb() {
39
+ return {
40
+ query: {
41
+ repositories: {
42
+ findMany: vi.fn().mockResolvedValue([
43
+ {
44
+ name: "my-project",
45
+ description: "A project",
46
+ owner: { username: "alice" },
47
+ starCount: 10,
48
+ },
49
+ ]),
50
+ },
51
+ users: {
52
+ findMany: vi
53
+ .fn()
54
+ .mockResolvedValue([{ username: "alice", displayName: "Alice" }]),
55
+ },
56
+ issues: {
57
+ findMany: vi.fn().mockResolvedValue([
58
+ {
59
+ title: "Bug fix",
60
+ number: 1,
61
+ repository: { name: "my-project", owner: { username: "alice" } },
62
+ },
63
+ ]),
64
+ },
65
+ pullRequests: {
66
+ findMany: vi.fn().mockResolvedValue([
67
+ {
68
+ title: "Feature PR",
69
+ number: 2,
70
+ repository: { name: "my-project", owner: { username: "alice" } },
71
+ },
72
+ ]),
73
+ },
74
+ workflows: {
75
+ findMany: vi.fn().mockResolvedValue([
76
+ {
77
+ name: "CI Build",
78
+ repository: { name: "my-project", owner: { username: "alice" } },
79
+ },
80
+ ]),
81
+ },
82
+ },
83
+ };
84
+}
85
+
86
+let mockDb: ReturnType<typeof makeDb>;
87
+const readJson = (r: Response) => r.json();
88
+
89
+function ctx(query: string) {
90
+ return {
91
+ request: new Request(
92
+ `http://localhost/api/search?q=${encodeURIComponent(query)}`,
93
+ ),
94
+ } as any;
95
+}
96
+
97
+/* ─── import route ─── */
98
+import { GET } from "@/pages/api/search";
99
+
100
+/* ─── tests ─── */
101
+describe("GET /api/search", () => {
102
+ beforeEach(() => {
103
+ vi.clearAllMocks();
104
+ mockDb = makeDb();
105
+ });
106
+
107
+ it("returns results for valid query", async () => {
108
+ const res = await GET(ctx("project"));
109
+ expect(res.status).toBe(200);
110
+ const json = await readJson(res);
111
+ expect(json.results.length).toBeGreaterThan(0);
112
+ expect(json.results.some((r: any) => r.type === "repository")).toBe(true);
113
+ expect(json.results.some((r: any) => r.type === "user")).toBe(true);
114
+ expect(json.results.some((r: any) => r.type === "issue")).toBe(true);
115
+ expect(json.results.some((r: any) => r.type === "pr")).toBe(true);
116
+ expect(json.results.some((r: any) => r.type === "workflow")).toBe(true);
117
+ });
118
+
119
+ it("returns empty results for short query", async () => {
120
+ const res = await GET(ctx("a"));
121
+ expect(res.status).toBe(200);
122
+ const json = await readJson(res);
123
+ expect(json.results).toEqual([]);
124
+ });
125
+
126
+ it("returns empty results when no query", async () => {
127
+ const res = await GET({
128
+ request: new Request("http://localhost/api/search"),
129
+ } as any);
130
+ expect(res.status).toBe(200);
131
+ const json = await readJson(res);
132
+ expect(json.results).toEqual([]);
133
+ });
134
+
135
+ it("formats repository results correctly", async () => {
136
+ mockDb.query.users.findMany.mockResolvedValue([]);
137
+ mockDb.query.issues.findMany.mockResolvedValue([]);
138
+ mockDb.query.pullRequests.findMany.mockResolvedValue([]);
139
+ mockDb.query.workflows.findMany.mockResolvedValue([]);
140
+
141
+ const res = await GET(ctx("project"));
142
+ expect(res.status).toBe(200);
143
+ const json = await readJson(res);
144
+ const repoResult = json.results.find((r: any) => r.type === "repository");
145
+ expect(repoResult).toBeDefined();
146
+ expect(repoResult.title).toBe("alice/my-project");
147
+ expect(repoResult.url).toBe("/alice/my-project");
148
+ expect(repoResult.icon).toBe("repo");
149
+ });
150
+
151
+ it("formats user results correctly", async () => {
152
+ mockDb.query.repositories.findMany.mockResolvedValue([]);
153
+ mockDb.query.issues.findMany.mockResolvedValue([]);
154
+ mockDb.query.pullRequests.findMany.mockResolvedValue([]);
155
+ mockDb.query.workflows.findMany.mockResolvedValue([]);
156
+
157
+ const res = await GET(ctx("alice"));
158
+ expect(res.status).toBe(200);
159
+ const json = await readJson(res);
160
+ const userResult = json.results.find((r: any) => r.type === "user");
161
+ expect(userResult).toBeDefined();
162
+ expect(userResult.subtitle).toBe("@alice");
163
+ expect(userResult.url).toBe("/alice");
164
+ });
165
+
166
+ it("handles empty DB results gracefully", async () => {
167
+ mockDb.query.repositories.findMany.mockResolvedValue([]);
168
+ mockDb.query.users.findMany.mockResolvedValue([]);
169
+ mockDb.query.issues.findMany.mockResolvedValue([]);
170
+ mockDb.query.pullRequests.findMany.mockResolvedValue([]);
171
+ mockDb.query.workflows.findMany.mockResolvedValue([]);
172
+
173
+ const res = await GET(ctx("nonexistent"));
174
+ expect(res.status).toBe(200);
175
+ const json = await readJson(res);
176
+ expect(json.results).toEqual([]);
177
+ });
178
+});
0
179
▼tests/integration/user-route.test.ts
+133-0
1
+/**
2
+ * Integration tests for GET /api/user
3
+ */
4
+import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+/* ─── hoisted mocks ─── */
7
+const mocks = vi.hoisted(() => ({
8
+ getUserFromRequestMock: vi.fn().mockResolvedValue({
9
+ userId: "usr_1",
10
+ username: "alice",
11
+ email: "[email protected]",
12
+ }),
13
+ fakeSchema: { users: "users" },
14
+}));
15
+
16
+vi.mock("@/db", () => ({
17
+ getDatabase: () => mockDb,
18
+ schema: mocks.fakeSchema,
19
+}));
20
+
21
+vi.mock("@/db/schema/users", () => ({
22
+ users: { id: "id" },
23
+}));
24
+
25
+vi.mock("@/lib/auth", () => ({
26
+ getUserFromRequest: mocks.getUserFromRequestMock,
27
+}));
28
+
29
+vi.mock("@/lib/errors", () => ({
30
+ withErrorHandler: (fn: any) => fn,
31
+}));
32
+
33
+vi.mock("drizzle-orm", () => ({
34
+ eq: vi.fn((...a: any[]) => a),
35
+}));
36
+
37
+/* ─── helpers ─── */
38
+const fakeUser = {
39
+ id: "usr_1",
40
+ username: "alice",
41
+ email: "[email protected]",
42
+ displayName: "Alice",
43
+ avatarUrl: null,
44
+ bio: "dev",
45
+ location: "NYC",
46
+ website: "",
47
+ company: "ACME",
48
+ isAdmin: false,
49
+ createdAt: new Date(),
50
+};
51
+
52
+function makeDb() {
53
+ return {
54
+ query: {
55
+ users: {
56
+ findFirst: vi.fn().mockResolvedValue(fakeUser),
57
+ },
58
+ },
59
+ };
60
+}
61
+
62
+let mockDb: ReturnType<typeof makeDb>;
63
+const readJson = (r: Response) => r.json();
64
+
65
+function ctx() {
66
+ return {
67
+ request: new Request("http://localhost/api/user"),
68
+ } as any;
69
+}
70
+
71
+/* ─── import route ─── */
72
+import { GET } from "@/pages/api/user/index";
73
+
74
+/* ─── tests ─── */
75
+describe("GET /api/user", () => {
76
+ beforeEach(() => {
77
+ vi.clearAllMocks();
78
+ mockDb = makeDb();
79
+ });
80
+
81
+ it("returns 200 with user profile", async () => {
82
+ const res = await GET(ctx());
83
+ expect(res.status).toBe(200);
84
+ const json = await readJson(res);
85
+ expect(json.username).toBe("alice");
86
+ expect(json.email).toBe("[email protected]");
87
+ expect(json.id).toBe("usr_1");
88
+ expect(json.isAdmin).toBe(false);
89
+ });
90
+
91
+ it("returns 401 when not authenticated", async () => {
92
+ mocks.getUserFromRequestMock.mockResolvedValue(null);
93
+ const res = await GET(ctx());
94
+ expect(res.status).toBe(401);
95
+ });
96
+
97
+ it("returns 401 when user not found in db", async () => {
98
+ mockDb.query.users.findFirst.mockResolvedValue(null);
99
+ const res = await GET(ctx());
100
+ expect(res.status).toBe(401);
101
+ });
102
+
103
+ it("does not include passwordHash in response", async () => {
104
+ mockDb.query.users.findFirst.mockResolvedValue({
105
+ ...fakeUser,
106
+ passwordHash: "secret_hash",
107
+ });
108
+ const res = await GET(ctx());
109
+ const json = await readJson(res);
110
+ expect(json.passwordHash).toBeUndefined();
111
+ });
112
+
113
+ it("includes all expected fields", async () => {
114
+ const res = await GET(ctx());
115
+ const json = await readJson(res);
116
+ const expectedFields = [
117
+ "id",
118
+ "username",
119
+ "email",
120
+ "displayName",
121
+ "avatarUrl",
122
+ "bio",
123
+ "location",
124
+ "website",
125
+ "company",
126
+ "isAdmin",
127
+ "createdAt",
128
+ ];
129
+ for (const field of expectedFields) {
130
+ expect(json).toHaveProperty(field);
131
+ }
132
+ });
133
+});
0
134
▼tests/load/README.md
+74-0
1
+# Load Testing
2
+
3
+k6-based load tests for OpenCodeHub. These scripts validate performance under various load conditions.
4
+
5
+## Prerequisites
6
+
7
+Install [k6](https://k6.io/docs/get-started/installation/):
8
+
9
+```bash
10
+# macOS
11
+brew install k6
12
+
13
+# Linux (Debian/Ubuntu)
14
+sudo gpg -k
15
+sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D68
16
+echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
17
+sudo apt-get update && sudo apt-get install k6
18
+
19
+# Docker
20
+docker run --rm -i grafana/k6 run - <script.js
21
+```
22
+
23
+## Test Scenarios
24
+
25
+| Script | Purpose | Users | Target |
26
+| --------------------- | -------------- | -------- | ----------- |
27
+| `k6-smoke.js` | Sanity check | 5 | P95 < 500ms |
28
+| `k6-load.js` | Sustained load | 100 | P99 < 1s |
29
+| `k6-stress.js` | Beyond limits | 500 | P99 < 3s |
30
+| `k6-spike.js` | Sudden burst | 10 → 500 | P95 < 3s |
31
+| `k6-api-lifecycle.js` | API flows | 50 | P99 < 1s |
32
+
33
+## Running
34
+
35
+```bash
36
+# Start the app first
37
+npm run build && npm run preview
38
+
39
+# Smoke test (quick)
40
+k6 run tests/load/k6-smoke.js
41
+
42
+# Load test (100 users, 11 minutes)
43
+k6 run tests/load/k6-load.js
44
+
45
+# Stress test (500 users, 16 minutes)
46
+k6 run tests/load/k6-stress.js
47
+
48
+# Spike test (burst to 500)
49
+k6 run tests/load/k6-spike.js
50
+
51
+# API lifecycle (authenticated)
52
+k6 run tests/load/k6-api-lifecycle.js --env AUTH_TOKEN=your_token
53
+
54
+# Custom base URL
55
+k6 run tests/load/k6-load.js --env BASE_URL=https://staging.example.com
56
+```
57
+
58
+## Dashboard Output
59
+
60
+Export to InfluxDB/Grafana for visual dashboards:
61
+
62
+```bash
63
+k6 run --out influxdb=http://localhost:8086/k6 tests/load/k6-load.js
64
+```
65
+
66
+## Thresholds
67
+
68
+Tests will **fail** if thresholds are not met:
69
+
70
+- **Smoke**: P95 < 500ms, <1% error rate
71
+- **Load**: P99 < 1s, <5% error rate
72
+- **Stress**: P99 < 3s, <10% error rate, <50 5xx errors
73
+- **Spike**: P95 < 3s, <15% error rate during spike
74
+- **API**: P99 < 1s, <5% error rate
0
75
▼tests/load/k6-api-lifecycle.js
+88-0
1
+/**
2
+ * k6 API Lifecycle Test — authenticated user flows
3
+ * Tests: issue CRUD, repository browse, search
4
+ * Run: k6 run tests/load/k6-api-lifecycle.js --env AUTH_TOKEN=<token>
5
+ */
6
+import { check, group, sleep } from "k6";
7
+import http from "k6/http";
8
+import { Counter, Trend } from "k6/metrics";
9
+
10
+const BASE_URL = __ENV.BASE_URL || "http://localhost:4321";
11
+const AUTH_TOKEN = __ENV.AUTH_TOKEN || "";
12
+
13
+const apiLatency = new Trend("api_latency", true);
14
+const apiErrors = new Counter("api_errors");
15
+
16
+export const options = {
17
+ stages: [
18
+ { duration: "1m", target: 20 },
19
+ { duration: "3m", target: 50 },
20
+ { duration: "2m", target: 50 },
21
+ { duration: "1m", target: 0 },
22
+ ],
23
+ thresholds: {
24
+ api_latency: ["p(95)<500", "p(99)<1000"],
25
+ api_errors: ["count<20"],
26
+ http_req_failed: ["rate<0.05"],
27
+ },
28
+};
29
+
30
+function apiGet(path) {
31
+ const headers = AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {};
32
+ return http.get(`${BASE_URL}${path}`, { headers });
33
+}
34
+
35
+function apiPost(path, body) {
36
+ const headers = {
37
+ "Content-Type": "application/json",
38
+ ...(AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {}),
39
+ };
40
+ return http.post(`${BASE_URL}${path}`, JSON.stringify(body), { headers });
41
+}
42
+
43
+export default function () {
44
+ // Scenario 1: Browse repositories
45
+ group("Browse Repos", () => {
46
+ const res = apiGet("/api/repos?page=1&limit=20");
47
+ check(res, { "repos list 2xx": (r) => r.status < 400 });
48
+ apiLatency.add(res.timings.duration);
49
+ if (res.status >= 400) apiErrors.add(1);
50
+ sleep(0.5);
51
+ });
52
+
53
+ // Scenario 2: Search
54
+ group("Search", () => {
55
+ const res = apiGet("/api/search?q=test&type=repos");
56
+ check(res, { "search 2xx": (r) => r.status < 400 });
57
+ apiLatency.add(res.timings.duration);
58
+ if (res.status >= 400) apiErrors.add(1);
59
+ sleep(0.5);
60
+ });
61
+
62
+ // Scenario 3: Get repo details (use a known test repo or random)
63
+ group("Repo Detail", () => {
64
+ const res = apiGet("/api/repos/admin/test-repo");
65
+ // 404 is acceptable in load testing — we just care about latency
66
+ check(res, { "repo detail ≤ 404": (r) => r.status <= 404 });
67
+ apiLatency.add(res.timings.duration);
68
+ sleep(0.3);
69
+ });
70
+
71
+ // Scenario 4: Issues list
72
+ group("Issues List", () => {
73
+ const res = apiGet("/api/repos/admin/test-repo/issues?page=1");
74
+ check(res, { "issues ≤ 404": (r) => r.status <= 404 });
75
+ apiLatency.add(res.timings.duration);
76
+ sleep(0.5);
77
+ });
78
+
79
+ // Scenario 5: Metrics endpoint
80
+ group("Metrics", () => {
81
+ const res = apiGet("/api/metrics");
82
+ check(res, { "metrics 2xx": (r) => r.status < 400 });
83
+ apiLatency.add(res.timings.duration);
84
+ sleep(0.3);
85
+ });
86
+
87
+ sleep(1);
88
+}
0
89
▼tests/load/k6-load.js
+67-0
1
+/**
2
+ * k6 Load Test — sustained load: 100 concurrent users
3
+ * Target: P99 < 1s at 100 users
4
+ * Run: k6 run tests/load/k6-load.js
5
+ */
6
+import { check, group, sleep } from "k6";
7
+import http from "k6/http";
8
+import { Counter, Trend } from "k6/metrics";
9
+
10
+const BASE_URL = __ENV.BASE_URL || "http://localhost:4321";
11
+
12
+const slowRequests = new Counter("slow_requests");
13
+const apiDuration = new Trend("api_request_duration", true);
14
+
15
+export const options = {
16
+ stages: [
17
+ { duration: "1m", target: 50 }, // Ramp up to 50
18
+ { duration: "3m", target: 100 }, // Ramp to 100
19
+ { duration: "5m", target: 100 }, // Sustain 100
20
+ { duration: "2m", target: 0 }, // Ramp down
21
+ ],
22
+ thresholds: {
23
+ http_req_duration: ["p(95)<800", "p(99)<1000"],
24
+ http_req_failed: ["rate<0.05"],
25
+ slow_requests: ["count<100"],
26
+ },
27
+};
28
+
29
+export default function () {
30
+ group("Browse Repositories", () => {
31
+ const res = http.get(`${BASE_URL}/explore`);
32
+ check(res, { "explore 2xx": (r) => r.status < 400 });
33
+ if (res.timings.duration > 1000) slowRequests.add(1);
34
+ apiDuration.add(res.timings.duration);
35
+ sleep(0.5);
36
+ });
37
+
38
+ group("API Health", () => {
39
+ const res = http.get(`${BASE_URL}/api/health`);
40
+ check(res, { "health ok": (r) => r.status === 200 });
41
+ apiDuration.add(res.timings.duration);
42
+ sleep(0.3);
43
+ });
44
+
45
+ group("Homepage", () => {
46
+ const res = http.get(`${BASE_URL}/`);
47
+ check(res, { "home 2xx": (r) => r.status < 400 });
48
+ if (res.timings.duration > 1000) slowRequests.add(1);
49
+ apiDuration.add(res.timings.duration);
50
+ sleep(0.5);
51
+ });
52
+
53
+ group("Static Assets", () => {
54
+ const res = http.get(`${BASE_URL}/favicon.svg`);
55
+ check(res, { "favicon ok": (r) => r.status < 400 });
56
+ sleep(0.2);
57
+ });
58
+
59
+ group("Sign-in Page", () => {
60
+ const res = http.get(`${BASE_URL}/auth/login`);
61
+ check(res, { "login page 2xx": (r) => r.status < 400 });
62
+ apiDuration.add(res.timings.duration);
63
+ sleep(0.5);
64
+ });
65
+
66
+ sleep(1);
67
+}
0
68
▼tests/load/k6-smoke.js
+47-0
1
+/**
2
+ * k6 Smoke Test — basic sanity check under minimal load
3
+ * Run: k6 run tests/load/k6-smoke.js
4
+ */
5
+import { check, sleep } from "k6";
6
+import http from "k6/http";
7
+
8
+const BASE_URL = __ENV.BASE_URL || "http://localhost:4321";
9
+
10
+export const options = {
11
+ stages: [
12
+ { duration: "30s", target: 5 },
13
+ { duration: "1m", target: 5 },
14
+ { duration: "30s", target: 0 },
15
+ ],
16
+ thresholds: {
17
+ http_req_duration: ["p(95)<500", "p(99)<1000"],
18
+ http_req_failed: ["rate<0.01"],
19
+ },
20
+};
21
+
22
+export default function () {
23
+ // 1. Homepage
24
+ const homeRes = http.get(`${BASE_URL}/`);
25
+ check(homeRes, {
26
+ "homepage status 200": (r) => r.status === 200,
27
+ "homepage time < 500ms": (r) => r.timings.duration < 500,
28
+ });
29
+
30
+ sleep(1);
31
+
32
+ // 2. Health endpoint
33
+ const healthRes = http.get(`${BASE_URL}/api/health`);
34
+ check(healthRes, {
35
+ "health status 200": (r) => r.status === 200,
36
+ });
37
+
38
+ sleep(0.5);
39
+
40
+ // 3. Explore/public repos
41
+ const exploreRes = http.get(`${BASE_URL}/explore`);
42
+ check(exploreRes, {
43
+ "explore status 200": (r) => r.status === 200,
44
+ });
45
+
46
+ sleep(1);
47
+}
0
48
▼tests/load/k6-spike.js
+41-0
1
+/**
2
+ * k6 Spike Test — sudden traffic burst
3
+ * Simulates viral event or DDoS-like burst
4
+ * Run: k6 run tests/load/k6-spike.js
5
+ */
6
+import { check, sleep } from "k6";
7
+import http from "k6/http";
8
+import { Counter } from "k6/metrics";
9
+
10
+const BASE_URL = __ENV.BASE_URL || "http://localhost:4321";
11
+
12
+const serverErrors = new Counter("server_errors_5xx");
13
+
14
+export const options = {
15
+ stages: [
16
+ { duration: "30s", target: 10 }, // Warm up
17
+ { duration: "30s", target: 500 }, // SPIKE!
18
+ { duration: "1m", target: 500 }, // Sustain spike
19
+ { duration: "30s", target: 10 }, // Cool down
20
+ { duration: "1m", target: 10 }, // Recovery
21
+ { duration: "30s", target: 0 }, // Ramp down
22
+ ],
23
+ thresholds: {
24
+ http_req_duration: ["p(95)<3000"],
25
+ http_req_failed: ["rate<0.15"], // Expect some failures during spike
26
+ },
27
+};
28
+
29
+export default function () {
30
+ const res = http.get(`${BASE_URL}/`);
31
+ check(res, {
32
+ "status ok": (r) => r.status < 500,
33
+ "not timeout": (r) => r.timings.duration < 10000,
34
+ });
35
+ if (res.status >= 500) serverErrors.add(1);
36
+ sleep(0.5);
37
+
38
+ const healthRes = http.get(`${BASE_URL}/api/health`);
39
+ check(healthRes, { "health alive": (r) => r.status === 200 });
40
+ sleep(0.5);
41
+}
0
42
▼tests/load/k6-stress.js
+60-0
1
+/**
2
+ * k6 Stress Test — push beyond limits: 500 concurrent users
3
+ * Target: P99 < 3s at 500 users, graceful degradation
4
+ * Run: k6 run tests/load/k6-stress.js
5
+ */
6
+import { check, group, sleep } from "k6";
7
+import http from "k6/http";
8
+import { Counter } from "k6/metrics";
9
+
10
+const BASE_URL = __ENV.BASE_URL || "http://localhost:4321";
11
+
12
+const serverErrors = new Counter("server_errors_5xx");
13
+
14
+export const options = {
15
+ stages: [
16
+ { duration: "2m", target: 100 },
17
+ { duration: "3m", target: 250 },
18
+ { duration: "3m", target: 500 },
19
+ { duration: "5m", target: 500 }, // Sustain peak
20
+ { duration: "2m", target: 100 },
21
+ { duration: "1m", target: 0 },
22
+ ],
23
+ thresholds: {
24
+ http_req_duration: ["p(95)<2000", "p(99)<3000"],
25
+ http_req_failed: ["rate<0.10"],
26
+ server_errors_5xx: ["count<50"],
27
+ },
28
+};
29
+
30
+export default function () {
31
+ group("Browse", () => {
32
+ const res = http.get(`${BASE_URL}/explore`);
33
+ check(res, { "explore 2xx": (r) => r.status < 400 });
34
+ if (res.status >= 500) serverErrors.add(1);
35
+ sleep(0.5);
36
+ });
37
+
38
+ group("API Health", () => {
39
+ const res = http.get(`${BASE_URL}/api/health`);
40
+ check(res, { "health ok": (r) => r.status === 200 });
41
+ if (res.status >= 500) serverErrors.add(1);
42
+ sleep(0.3);
43
+ });
44
+
45
+ group("Homepage", () => {
46
+ const res = http.get(`${BASE_URL}/`);
47
+ check(res, { "home ok": (r) => r.status < 400 });
48
+ if (res.status >= 500) serverErrors.add(1);
49
+ sleep(0.5);
50
+ });
51
+
52
+ group("Login Page", () => {
53
+ const res = http.get(`${BASE_URL}/auth/login`);
54
+ check(res, { "login 2xx": (r) => r.status < 400 });
55
+ if (res.status >= 500) serverErrors.add(1);
56
+ sleep(0.5);
57
+ });
58
+
59
+ sleep(1);
60
+}
0
61
▼tests/unit/air-gapped.test.ts
+27-0
1
+import { describe, expect, it, vi } from "vitest";
2
+import { assertOnlineFeature, getAirGappedMessage, isAirGappedMode } from "@/lib/air-gapped";
3
+
4
+describe("air-gapped mode", () => {
5
+ it("returns false by default", () => {
6
+ vi.stubEnv("AIR_GAPPED_MODE", "");
7
+ expect(isAirGappedMode()).toBe(false);
8
+ });
9
+
10
+ it("returns true for truthy values", () => {
11
+ vi.stubEnv("AIR_GAPPED_MODE", "true");
12
+ expect(isAirGappedMode()).toBe(true);
13
+
14
+ vi.stubEnv("AIR_GAPPED_MODE", "1");
15
+ expect(isAirGappedMode()).toBe(true);
16
+
17
+ vi.stubEnv("AIR_GAPPED_MODE", "yes");
18
+ expect(isAirGappedMode()).toBe(true);
19
+ });
20
+
21
+ it("throws when feature requires online mode", () => {
22
+ vi.stubEnv("AIR_GAPPED_MODE", "true");
23
+ expect(() => assertOnlineFeature("External CI")).toThrow(
24
+ getAirGappedMessage("External CI")
25
+ );
26
+ });
27
+});
0
28
▼tests/unit/api-helpers.test.ts
+208-0
1
+import {
2
+ badRequest,
3
+ conflict,
4
+ created,
5
+ forbidden,
6
+ getPagination,
7
+ noContent,
8
+ notFound,
9
+ paginationMeta,
10
+ parseQuery,
11
+ rateLimited,
12
+ serverError,
13
+ success,
14
+ unauthorized,
15
+ validationError,
16
+} from "@/lib/api";
17
+import { describe, expect, it } from "vitest";
18
+import { z } from "zod";
19
+
20
+describe("success", () => {
21
+ it("returns 200 JSON response", async () => {
22
+ const res = success({ name: "test" });
23
+ expect(res.status).toBe(200);
24
+ const body = await res.json();
25
+ expect(body.success).toBe(true);
26
+ expect(body.data.name).toBe("test");
27
+ });
28
+
29
+ it("includes meta when provided", async () => {
30
+ const meta = { page: 1, perPage: 10, total: 100 };
31
+ const res = success([1, 2, 3], meta);
32
+ const body = await res.json();
33
+ expect(body.meta).toBeDefined();
34
+ expect(body.meta.page).toBe(1);
35
+ });
36
+});
37
+
38
+describe("created", () => {
39
+ it("returns 201 JSON response", async () => {
40
+ const res = created({ id: "abc123" });
41
+ expect(res.status).toBe(201);
42
+ const body = await res.json();
43
+ expect(body.success).toBe(true);
44
+ });
45
+});
46
+
47
+describe("noContent", () => {
48
+ it("returns 204 with no body", () => {
49
+ const res = noContent();
50
+ expect(res.status).toBe(204);
51
+ });
52
+});
53
+
54
+describe("error responses", () => {
55
+ it("badRequest returns 400", async () => {
56
+ const res = badRequest("Invalid input");
57
+ expect(res.status).toBe(400);
58
+ const body = await res.json();
59
+ expect(body.success).toBe(false);
60
+ expect(body.error.message).toBe("Invalid input");
61
+ });
62
+
63
+ it("unauthorized returns 401", async () => {
64
+ const res = unauthorized();
65
+ expect(res.status).toBe(401);
66
+ });
67
+
68
+ it("unauthorized with custom message", async () => {
69
+ const res = unauthorized("Login required");
70
+ expect(res.status).toBe(401);
71
+ const body = await res.json();
72
+ expect(body.error.message).toBe("Login required");
73
+ });
74
+
75
+ it("forbidden returns 403", async () => {
76
+ const res = forbidden();
77
+ expect(res.status).toBe(403);
78
+ });
79
+
80
+ it("notFound returns 404", async () => {
81
+ const res = notFound("Repo not found");
82
+ expect(res.status).toBe(404);
83
+ const body = await res.json();
84
+ expect(body.error.message).toBe("Repo not found");
85
+ });
86
+
87
+ it("conflict returns 409", async () => {
88
+ const res = conflict("Already exists");
89
+ expect(res.status).toBe(409);
90
+ const body = await res.json();
91
+ expect(body.error.message).toBe("Already exists");
92
+ });
93
+
94
+ it("serverError returns 500", async () => {
95
+ const res = serverError();
96
+ expect(res.status).toBe(500);
97
+ });
98
+
99
+ it("rateLimited returns 429", async () => {
100
+ const res = rateLimited(60);
101
+ expect(res.status).toBe(429);
102
+ expect(res.headers.get("Retry-After")).toBe("60");
103
+ });
104
+
105
+ it("validationError returns 400 with zod errors", async () => {
106
+ const schema = z.object({ name: z.string().min(1) });
107
+ const result = schema.safeParse({ name: "" });
108
+ if (!result.success) {
109
+ const res = validationError(result.error);
110
+ expect(res.status).toBe(400);
111
+ const body = await res.json();
112
+ expect(body.success).toBe(false);
113
+ }
114
+ });
115
+});
116
+
117
+describe("parseQuery", () => {
118
+ it("parses valid query parameters", () => {
119
+ const schema = z.object({
120
+ page: z.coerce.number().default(1),
121
+ search: z.string().optional(),
122
+ });
123
+ const url = new URL("http://localhost?page=3&search=hello");
124
+ const result = parseQuery(url, schema);
125
+ expect("data" in result).toBe(true);
126
+ if ("data" in result) {
127
+ expect(result.data.page).toBe(3);
128
+ expect(result.data.search).toBe("hello");
129
+ }
130
+ });
131
+
132
+ it("returns error for invalid query", () => {
133
+ const schema = z.object({
134
+ page: z.coerce.number().int().min(1),
135
+ });
136
+ const url = new URL("http://localhost?page=-5");
137
+ const result = parseQuery(url, schema);
138
+ expect("error" in result).toBe(true);
139
+ });
140
+
141
+ it("uses defaults when query params are missing", () => {
142
+ const schema = z.object({
143
+ page: z.coerce.number().default(1),
144
+ perPage: z.coerce.number().default(20),
145
+ });
146
+ const url = new URL("http://localhost");
147
+ const result = parseQuery(url, schema);
148
+ expect("data" in result).toBe(true);
149
+ if ("data" in result) {
150
+ expect(result.data.page).toBe(1);
151
+ expect(result.data.perPage).toBe(20);
152
+ }
153
+ });
154
+});
155
+
156
+describe("getPagination", () => {
157
+ it("returns default pagination for empty URL", () => {
158
+ const url = new URL("http://localhost");
159
+ const result = getPagination(url);
160
+ expect(result.page).toBe(1);
161
+ expect(result.perPage).toBeGreaterThan(0);
162
+ expect(result.offset).toBe(0);
163
+ });
164
+
165
+ it("parses page and perPage from query", () => {
166
+ const url = new URL("http://localhost?page=3&per_page=50");
167
+ const result = getPagination(url);
168
+ expect(result.page).toBe(3);
169
+ expect(result.perPage).toBe(50);
170
+ expect(result.offset).toBe(100);
171
+ });
172
+
173
+ it("clamps perPage to max", () => {
174
+ const url = new URL("http://localhost?per_page=9999");
175
+ const result = getPagination(url, 20, 100);
176
+ expect(result.perPage).toBeLessThanOrEqual(100);
177
+ });
178
+
179
+ it("defaults to page 1 for invalid page", () => {
180
+ const url = new URL("http://localhost?page=abc");
181
+ const result = getPagination(url);
182
+ expect(result.page).toBe(1);
183
+ });
184
+});
185
+
186
+describe("paginationMeta", () => {
187
+ it("calculates correct pagination metadata", () => {
188
+ const pagination = { page: 2, perPage: 10, offset: 10 };
189
+ const meta = paginationMeta(95, pagination);
190
+ expect(meta?.page).toBe(2);
191
+ expect(meta?.perPage).toBe(10);
192
+ expect(meta?.total).toBe(95);
193
+ expect(meta?.totalPages).toBe(10);
194
+ });
195
+
196
+ it("handles 0 total items", () => {
197
+ const pagination = { page: 1, perPage: 10, offset: 0 };
198
+ const meta = paginationMeta(0, pagination);
199
+ expect(meta?.total).toBe(0);
200
+ expect(meta?.totalPages).toBe(0);
201
+ });
202
+
203
+ it("handles single page", () => {
204
+ const pagination = { page: 1, perPage: 10, offset: 0 };
205
+ const meta = paginationMeta(5, pagination);
206
+ expect(meta?.totalPages).toBe(1);
207
+ });
208
+});
0
209
▼tests/unit/ci-formats.test.ts
+118-0
1
+import type { UnifiedWorkflow } from "@/lib/ci-formats";
2
+import { toGitHubActions, toGitLabCI } from "@/lib/ci-formats";
3
+import { describe, expect, it } from "vitest";
4
+
5
+const sampleWorkflow: UnifiedWorkflow = {
6
+ name: "CI Pipeline",
7
+ triggers: ["push", "pull_request"],
8
+ env: { NODE_ENV: "test" },
9
+ jobs: [
10
+ {
11
+ name: "build",
12
+ runsOn: "ubuntu-latest",
13
+ steps: [
14
+ { name: "Checkout", uses: "actions/checkout@v4" },
15
+ {
16
+ name: "Setup Node",
17
+ uses: "actions/setup-node@v4",
18
+ with: { "node-version": "20" },
19
+ },
20
+ { name: "Install deps", run: "npm ci" },
21
+ { name: "Build", run: "npm run build" },
22
+ ],
23
+ },
24
+ {
25
+ name: "test",
26
+ runsOn: "ubuntu-latest",
27
+ needs: ["build"],
28
+ steps: [
29
+ { name: "Checkout", uses: "actions/checkout@v4" },
30
+ { name: "Run tests", run: "npm test" },
31
+ ],
32
+ },
33
+ ],
34
+};
35
+
36
+describe("toGitHubActions", () => {
37
+ it("generates valid YAML string", () => {
38
+ const result = toGitHubActions(sampleWorkflow);
39
+ expect(typeof result).toBe("string");
40
+ expect(result.length).toBeGreaterThan(0);
41
+ });
42
+
43
+ it("includes workflow name", () => {
44
+ const result = toGitHubActions(sampleWorkflow);
45
+ expect(result).toContain("CI Pipeline");
46
+ });
47
+
48
+ it("includes job definitions", () => {
49
+ const result = toGitHubActions(sampleWorkflow);
50
+ expect(result).toContain("build");
51
+ expect(result).toContain("test");
52
+ });
53
+
54
+ it("includes steps", () => {
55
+ const result = toGitHubActions(sampleWorkflow);
56
+ expect(result).toContain("npm ci");
57
+ expect(result).toContain("npm run build");
58
+ expect(result).toContain("npm test");
59
+ });
60
+
61
+ it("includes uses references", () => {
62
+ const result = toGitHubActions(sampleWorkflow);
63
+ expect(result).toContain("actions/checkout@v4");
64
+ expect(result).toContain("actions/setup-node@v4");
65
+ });
66
+
67
+ it("handles minimal workflow", () => {
68
+ const minimal: UnifiedWorkflow = {
69
+ name: "Minimal",
70
+ triggers: ["push"],
71
+ jobs: [
72
+ {
73
+ name: "hello",
74
+ steps: [{ name: "Say hello", run: 'echo "hello"' }],
75
+ },
76
+ ],
77
+ };
78
+ const result = toGitHubActions(minimal);
79
+ expect(result).toContain("Minimal");
80
+ expect(result).toContain("echo");
81
+ });
82
+});
83
+
84
+describe("toGitLabCI", () => {
85
+ it("generates valid YAML string", () => {
86
+ const result = toGitLabCI(sampleWorkflow);
87
+ expect(typeof result).toBe("string");
88
+ expect(result.length).toBeGreaterThan(0);
89
+ });
90
+
91
+ it("includes stage/job definitions", () => {
92
+ const result = toGitLabCI(sampleWorkflow);
93
+ expect(result).toContain("build");
94
+ expect(result).toContain("test");
95
+ });
96
+
97
+ it("includes script commands", () => {
98
+ const result = toGitLabCI(sampleWorkflow);
99
+ expect(result).toContain("npm ci");
100
+ expect(result).toContain("npm run build");
101
+ expect(result).toContain("npm test");
102
+ });
103
+
104
+ it("handles minimal workflow", () => {
105
+ const minimal: UnifiedWorkflow = {
106
+ name: "Minimal",
107
+ triggers: ["push"],
108
+ jobs: [
109
+ {
110
+ name: "hello",
111
+ steps: [{ name: "Say hello", run: 'echo "hello"' }],
112
+ },
113
+ ],
114
+ };
115
+ const result = toGitLabCI(minimal);
116
+ expect(result).toContain("echo");
117
+ });
118
+});
0
119
▼tests/unit/cn.test.ts
+44-0
1
+import { cn } from "@/lib/cn";
2
+import { describe, expect, it } from "vitest";
3
+
4
+describe("cn", () => {
5
+ it("merges simple class names", () => {
6
+ expect(cn("foo", "bar")).toBe("foo bar");
7
+ });
8
+
9
+ it("handles conditional classes", () => {
10
+ expect(cn("base", true && "active", false && "hidden")).toBe("base active");
11
+ });
12
+
13
+ it("handles undefined and null", () => {
14
+ expect(cn("base", undefined, null, "end")).toBe("base end");
15
+ });
16
+
17
+ it("handles empty string", () => {
18
+ expect(cn("")).toBe("");
19
+ });
20
+
21
+ it("handles no arguments", () => {
22
+ expect(cn()).toBe("");
23
+ });
24
+
25
+ it("merges tailwind classes correctly", () => {
26
+ // twMerge should handle conflicting tailwind classes
27
+ const result = cn("px-2 py-1", "px-4");
28
+ expect(result).toContain("px-4");
29
+ expect(result).not.toContain("px-2");
30
+ });
31
+
32
+ it("handles array input", () => {
33
+ const result = cn(["foo", "bar"]);
34
+ expect(result).toContain("foo");
35
+ expect(result).toContain("bar");
36
+ });
37
+
38
+ it("handles object input", () => {
39
+ const result = cn({ active: true, hidden: false, visible: true });
40
+ expect(result).toContain("active");
41
+ expect(result).toContain("visible");
42
+ expect(result).not.toContain("hidden");
43
+ });
44
+});
0
45
▼tests/unit/codeowners.test.ts
+157-0
1
+import {
2
+ CODEOWNERS_PATHS,
3
+ findOwnersForFile,
4
+ findOwnersForFiles,
5
+ parseCodeOwners,
6
+ validateCodeOwners,
7
+} from "@/lib/codeowners";
8
+import { describe, expect, it } from "vitest";
9
+
10
+describe("CODEOWNERS_PATHS", () => {
11
+ it("contains expected paths", () => {
12
+ expect(CODEOWNERS_PATHS).toContain("CODEOWNERS");
13
+ expect(CODEOWNERS_PATHS).toContain(".github/CODEOWNERS");
14
+ expect(CODEOWNERS_PATHS).toContain("docs/CODEOWNERS");
15
+ });
16
+});
17
+
18
+describe("parseCodeOwners", () => {
19
+ it("parses simple ownership rules", () => {
20
+ const content = `
21
+# This is a comment
22
+*.js @frontend-team
23
+/docs/* @docs-team
24
+*.ts @typescript-team
25
+ `.trim();
26
+
27
+ const result = parseCodeOwners(content);
28
+ expect(result.rules).toBeDefined();
29
+ expect(result.rules.length).toBe(3);
30
+ });
31
+
32
+ it("handles empty content", () => {
33
+ const result = parseCodeOwners("");
34
+ expect(result.rules).toBeDefined();
35
+ expect(result.rules.length).toBe(0);
36
+ });
37
+
38
+ it("ignores comment lines", () => {
39
+ const content = `
40
+# Comment only
41
+# Another comment
42
+ `.trim();
43
+
44
+ const result = parseCodeOwners(content);
45
+ expect(result.rules.length).toBe(0);
46
+ });
47
+
48
+ it("ignores blank lines", () => {
49
+ const content = `
50
+*.js @owner1
51
+
52
+*.ts @owner2
53
+ `;
54
+
55
+ const result = parseCodeOwners(content);
56
+ expect(result.rules.length).toBe(2);
57
+ });
58
+
59
+ it("parses multiple owners per rule", () => {
60
+ const content = "*.js @owner1 @owner2 [email protected]";
61
+ const result = parseCodeOwners(content);
62
+ expect(result.rules.length).toBe(1);
63
+ expect(result.rules[0].owners.length).toBeGreaterThanOrEqual(2);
64
+ });
65
+});
66
+
67
+describe("findOwnersForFile", () => {
68
+ const content = `
69
+*.js @js-team
70
+*.ts @ts-team
71
+/docs/* @docs-team
72
+src/lib/* @core-team
73
+ `.trim();
74
+
75
+ const codeOwners = parseCodeOwners(content);
76
+
77
+ it("matches .js files", () => {
78
+ const owners = findOwnersForFile(codeOwners, "app.js");
79
+ expect(owners).toContain("@js-team");
80
+ });
81
+
82
+ it("matches .ts files", () => {
83
+ const owners = findOwnersForFile(codeOwners, "index.ts");
84
+ expect(owners).toContain("@ts-team");
85
+ });
86
+
87
+ it("matches docs directory", () => {
88
+ const owners = findOwnersForFile(codeOwners, "docs/readme.md");
89
+ expect(owners).toContain("@docs-team");
90
+ });
91
+
92
+ it("matches src/lib directory", () => {
93
+ const owners = findOwnersForFile(codeOwners, "src/lib/utils.ts");
94
+ // Should get both @core-team (from src/lib/*) and @ts-team (from *.ts)
95
+ // Last matching pattern wins in CODEOWNERS
96
+ expect(owners.length).toBeGreaterThan(0);
97
+ });
98
+
99
+ it("returns empty for unmatched files", () => {
100
+ const owners = findOwnersForFile(codeOwners, "Makefile");
101
+ // No patterns match Makefile — returns empty
102
+ expect(Array.isArray(owners)).toBe(true);
103
+ });
104
+});
105
+
106
+describe("findOwnersForFiles", () => {
107
+ const content = `
108
+*.js @js-team
109
+*.py @python-team
110
+ `.trim();
111
+
112
+ const codeOwners = parseCodeOwners(content);
113
+
114
+ it("returns combined owners for multiple files", () => {
115
+ const owners = findOwnersForFiles(codeOwners, ["app.js", "script.py"]);
116
+ expect(owners).toContain("@js-team");
117
+ expect(owners).toContain("@python-team");
118
+ });
119
+
120
+ it("deduplicates owners", () => {
121
+ const owners = findOwnersForFiles(codeOwners, ["a.js", "b.js"]);
122
+ const jsTeamCount = owners.filter((o) => o === "@js-team").length;
123
+ expect(jsTeamCount).toBe(1);
124
+ });
125
+
126
+ it("handles empty file list", () => {
127
+ const owners = findOwnersForFiles(codeOwners, []);
128
+ expect(owners.length).toBe(0);
129
+ });
130
+});
131
+
132
+describe("validateCodeOwners", () => {
133
+ it("validates correct CODEOWNERS content", () => {
134
+ const content = "*.js @frontend\n/docs/* @docs-team";
135
+ const result = validateCodeOwners(content);
136
+ expect(result.valid).toBe(true);
137
+ expect(result.errors.length).toBe(0);
138
+ });
139
+
140
+ it("reports error for rules without owners", () => {
141
+ const content = "*.js\n*.ts @ts-team";
142
+ const result = validateCodeOwners(content);
143
+ // A rule without owners should trigger an error/warning
144
+ expect(result.errors.length + result.warnings.length).toBeGreaterThan(0);
145
+ });
146
+
147
+ it("handles empty content", () => {
148
+ const result = validateCodeOwners("");
149
+ expect(result.valid).toBe(true);
150
+ expect(result.errors.length).toBe(0);
151
+ });
152
+
153
+ it("handles comment-only content", () => {
154
+ const result = validateCodeOwners("# just comments\n# more comments");
155
+ expect(result.valid).toBe(true);
156
+ });
157
+});
0
158
▼tests/unit/email.test.ts
+44-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+vi.mock("@/lib/logger", () => ({
4
+ logger: {
5
+ info: vi.fn(),
6
+ warn: vi.fn(),
7
+ error: vi.fn(),
8
+ debug: vi.fn(),
9
+ },
10
+}));
11
+
12
+// Mock nodemailer
13
+vi.mock("nodemailer", () => ({
14
+ default: {
15
+ createTransport: vi.fn(() => ({
16
+ sendMail: vi.fn().mockResolvedValue({ messageId: "test-123" }),
17
+ })),
18
+ },
19
+ createTransport: vi.fn(() => ({
20
+ sendMail: vi.fn().mockResolvedValue({ messageId: "test-123" }),
21
+ })),
22
+}));
23
+
24
+describe("email", () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ it("exports isSmtpConfigured function", async () => {
30
+ const mod = await import("@/lib/email");
31
+ expect(typeof mod.isSmtpConfigured).toBe("function");
32
+ });
33
+
34
+ it("exports sendEmail function", async () => {
35
+ const mod = await import("@/lib/email");
36
+ expect(typeof mod.sendEmail).toBe("function");
37
+ });
38
+
39
+ it("isSmtpConfigured returns boolean", async () => {
40
+ const { isSmtpConfigured } = await import("@/lib/email");
41
+ const result = isSmtpConfigured();
42
+ expect(typeof result).toBe("boolean");
43
+ });
44
+});
0
45
▼tests/unit/env-validation.test.ts
+29-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+vi.mock("@/lib/logger", () => ({
4
+ logger: {
5
+ info: vi.fn(),
6
+ warn: vi.fn(),
7
+ error: vi.fn(),
8
+ debug: vi.fn(),
9
+ },
10
+}));
11
+
12
+describe("env-validation", () => {
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ });
16
+
17
+ it("validates environment without crashing", async () => {
18
+ const { validateEnvironment } = await import("@/lib/env-validation");
19
+ // Pass false to avoid process.exit on error
20
+ const result = validateEnvironment(false);
21
+ expect(typeof result).toBe("boolean");
22
+ });
23
+
24
+ it("returns boolean result", async () => {
25
+ const { validateEnvironment } = await import("@/lib/env-validation");
26
+ const result = validateEnvironment(false);
27
+ expect(typeof result).toBe("boolean");
28
+ });
29
+});
0
30
▼tests/unit/markdown.test.ts
+124-0
1
+import { renderMarkdown } from "@/lib/markdown";
2
+import { describe, expect, it } from "vitest";
3
+
4
+describe("renderMarkdown", () => {
5
+ it("renders simple text", async () => {
6
+ const html = await renderMarkdown("Hello world");
7
+ expect(html).toContain("Hello world");
8
+ });
9
+
10
+ it("renders headings", async () => {
11
+ const html = await renderMarkdown("# Title\n## Subtitle");
12
+ expect(html).toContain("<h1");
13
+ expect(html).toContain("Title");
14
+ expect(html).toContain("<h2");
15
+ expect(html).toContain("Subtitle");
16
+ });
17
+
18
+ it("renders bold and italic", async () => {
19
+ const html = await renderMarkdown("**bold** and *italic*");
20
+ expect(html).toContain("<strong>bold</strong>");
21
+ expect(html).toContain("<em>italic</em>");
22
+ });
23
+
24
+ it("renders links", async () => {
25
+ const html = await renderMarkdown("[OpenCodeHub](https://example.com)");
26
+ expect(html).toContain("<a");
27
+ expect(html).toContain("https://example.com");
28
+ });
29
+
30
+ it("renders code blocks", async () => {
31
+ const html = await renderMarkdown("```js\nconst x = 1;\n```");
32
+ expect(html).toContain("<code");
33
+ expect(html).toContain("const x = 1;");
34
+ });
35
+
36
+ it("renders inline code", async () => {
37
+ const html = await renderMarkdown("Use `npm install` to install");
38
+ expect(html).toContain("<code");
39
+ expect(html).toContain("npm install");
40
+ });
41
+
42
+ it("renders unordered lists", async () => {
43
+ const html = await renderMarkdown("- Item 1\n- Item 2\n- Item 3");
44
+ expect(html).toContain("<ul");
45
+ expect(html).toContain("<li");
46
+ expect(html).toContain("Item 1");
47
+ });
48
+
49
+ it("renders ordered lists", async () => {
50
+ const html = await renderMarkdown("1. First\n2. Second");
51
+ expect(html).toContain("<ol");
52
+ expect(html).toContain("First");
53
+ });
54
+
55
+ it("renders GFM tables", async () => {
56
+ const md = `
57
+| Column A | Column B |
58
+|----------|----------|
59
+| Val 1 | Val 2 |
60
+ `.trim();
61
+ const html = await renderMarkdown(md);
62
+ expect(html).toContain("<table");
63
+ expect(html).toContain("Column A");
64
+ expect(html).toContain("Val 1");
65
+ });
66
+
67
+ it("renders GFM task lists", async () => {
68
+ const md = "- [x] Done\n- [ ] Todo";
69
+ const html = await renderMarkdown(md);
70
+ expect(html).toContain("Done");
71
+ expect(html).toContain("Todo");
72
+ });
73
+
74
+ it("renders GFM strikethrough", async () => {
75
+ const html = await renderMarkdown("~~deleted~~");
76
+ expect(html).toContain("<del");
77
+ expect(html).toContain("deleted");
78
+ });
79
+
80
+ it("sanitizes dangerous html", async () => {
81
+ const html = await renderMarkdown('<script>alert("xss")</script>');
82
+ expect(html).not.toContain("<script");
83
+ });
84
+
85
+ it("removes dangerous attributes", async () => {
86
+ const html = await renderMarkdown('<img onerror="alert(1)" src="x">');
87
+ expect(html).not.toContain("onerror");
88
+ });
89
+
90
+ it("handles empty string", async () => {
91
+ const html = await renderMarkdown("");
92
+ expect(typeof html).toBe("string");
93
+ });
94
+
95
+ it("handles whitespace-only content", async () => {
96
+ const html = await renderMarkdown(" \n\n ");
97
+ expect(typeof html).toBe("string");
98
+ });
99
+
100
+ it("renders cross-repo issue references", async () => {
101
+ const html = await renderMarkdown("See owner/repo#123 for details");
102
+ // The remarkCrossRepoLinks plugin should convert owner/repo#123 to a link
103
+ expect(html).toContain("owner/repo#123");
104
+ });
105
+
106
+ it("renders blockquotes", async () => {
107
+ const html = await renderMarkdown("> This is a quote");
108
+ expect(html).toContain("<blockquote");
109
+ expect(html).toContain("This is a quote");
110
+ });
111
+
112
+ it("renders horizontal rules", async () => {
113
+ const html = await renderMarkdown("---");
114
+ expect(html).toContain("<hr");
115
+ });
116
+
117
+ it("renders images", async () => {
118
+ const html = await renderMarkdown(
119
+ "![alt text](https://example.com/img.png)",
120
+ );
121
+ expect(html).toContain("<img");
122
+ expect(html).toContain("alt text");
123
+ });
124
+});
0
125
▼tests/unit/notification-routing.test.ts
+120-0
1
+import {
2
+ channelEnabled,
3
+ computePersonalizationBoost,
4
+ getRoutingDecision,
5
+} from "@/lib/notification-routing";
6
+import { describe, expect, it } from "vitest";
7
+
8
+describe("getRoutingDecision", () => {
9
+ it("returns default channels when no preferences", () => {
10
+ const decision = getRoutingDecision("push", []);
11
+ expect(decision.channels).toBeDefined();
12
+ expect(Array.isArray(decision.channels)).toBe(true);
13
+ expect(decision.channels.length).toBeGreaterThan(0);
14
+ expect(decision.primaryChannel).toBeDefined();
15
+ });
16
+
17
+ it("returns default channels for null event type", () => {
18
+ const decision = getRoutingDecision(null, []);
19
+ expect(decision.channels).toBeDefined();
20
+ expect(decision.channels.length).toBeGreaterThan(0);
21
+ });
22
+
23
+ it("returns default channels for undefined event type", () => {
24
+ const decision = getRoutingDecision(undefined, []);
25
+ expect(decision.channels).toBeDefined();
26
+ });
27
+
28
+ it("includes in_app channel by default", () => {
29
+ const decision = getRoutingDecision("issue_comment", []);
30
+ expect(decision.channels).toContain("in_app");
31
+ });
32
+
33
+ it("respects user preferences when provided", () => {
34
+ const prefs = [
35
+ {
36
+ id: "1",
37
+ userId: "u1",
38
+ repositoryId: null,
39
+ eventType: "push",
40
+ inApp: true,
41
+ email: false,
42
+ slack: false,
43
+ browserPush: false,
44
+ createdAt: new Date(),
45
+ updatedAt: new Date(),
46
+ },
47
+ ] as any[];
48
+ const decision = getRoutingDecision("push", prefs);
49
+ expect(decision.channels).toContain("in_app");
50
+ });
51
+
52
+ it("returns a valid primaryChannel", () => {
53
+ const decision = getRoutingDecision("review_request", []);
54
+ expect(decision.channels).toContain(decision.primaryChannel);
55
+ });
56
+});
57
+
58
+describe("channelEnabled", () => {
59
+ it("returns true when channel is in decision", () => {
60
+ const decision = {
61
+ channels: ["in_app", "email"] as any[],
62
+ primaryChannel: "in_app" as any,
63
+ };
64
+ expect(channelEnabled("in_app", decision)).toBe(true);
65
+ expect(channelEnabled("email", decision)).toBe(true);
66
+ });
67
+
68
+ it("returns false when channel is not in decision", () => {
69
+ const decision = {
70
+ channels: ["in_app"] as any[],
71
+ primaryChannel: "in_app" as any,
72
+ };
73
+ expect(channelEnabled("email", decision)).toBe(false);
74
+ expect(channelEnabled("slack", decision)).toBe(false);
75
+ });
76
+});
77
+
78
+describe("computePersonalizationBoost", () => {
79
+ it("returns 0 for empty input", () => {
80
+ const boost = computePersonalizationBoost({
81
+ eventType: "push",
82
+ readByType: {},
83
+ unreadByType: {},
84
+ baselineReadRatio: 0.5,
85
+ });
86
+ expect(typeof boost).toBe("number");
87
+ });
88
+
89
+ it("returns a positive boost for frequently-read event types", () => {
90
+ const boost = computePersonalizationBoost({
91
+ eventType: "push",
92
+ readByType: { push: 50 },
93
+ unreadByType: { push: 5 },
94
+ baselineReadRatio: 0.5,
95
+ });
96
+ // User reads push events a lot — boost should be positive (higher priority)
97
+ expect(boost).toBeGreaterThanOrEqual(0);
98
+ });
99
+
100
+ it("returns zero or negative boost for mostly-unread event types", () => {
101
+ const boost = computePersonalizationBoost({
102
+ eventType: "star",
103
+ readByType: { star: 1 },
104
+ unreadByType: { star: 50 },
105
+ baselineReadRatio: 0.5,
106
+ });
107
+ expect(boost).toBeLessThanOrEqual(0);
108
+ });
109
+
110
+ it("handles unknown event type gracefully", () => {
111
+ const boost = computePersonalizationBoost({
112
+ eventType: "unknown_event",
113
+ readByType: {},
114
+ unreadByType: {},
115
+ baselineReadRatio: 0.5,
116
+ });
117
+ expect(typeof boost).toBe("number");
118
+ expect(Number.isFinite(boost)).toBe(true);
119
+ });
120
+});
0
121
▼tests/unit/notifications-route.test.ts
+2-1
47
47
getUserFromRequestMock.mockResolvedValue({ userId: "user-1" });
48
48
mockDb.query.notifications.findMany
49
49
.mockResolvedValueOnce([{ id: "notif-1", type: "ci_failed" }])
50
+ .mockResolvedValueOnce([{ id: "history-1", type: "ci_failed", isRead: false }])
50
51
.mockResolvedValueOnce([{ id: "notif-2" }, { id: "notif-3" }]);
51
52
52
53
const response = await listNotificationsGet({
60
61
expect(body?.success).toBe(true);
61
62
expect(body?.data?.notifications).toHaveLength(1);
62
63
expect(body?.data?.unreadCount).toBe(2);
63
- expect(mockDb.query.notifications.findMany).toHaveBeenCalledTimes(2);
64
+ expect(mockDb.query.notifications.findMany).toHaveBeenCalledTimes(3);
64
65
});
65
66
66
67
it("sorts by priority score when prioritize=true", async () => {
67
68
▼tests/unit/openapi-notifications-parity.test.ts
+20-0
1
+import { describe, expect, it } from "vitest";
2
+import { openApiSpec } from "@/lib/openapi";
3
+
4
+describe("openapi notifications parity", () => {
5
+ it("includes notification prioritization, blocking, and digest operations", () => {
6
+ const paths = openApiSpec.paths as Record<string, unknown>;
7
+
8
+ expect(paths["/notifications"]).toBeTruthy();
9
+ expect(paths["/notifications/blocking/summary"]).toBeTruthy();
10
+ expect(paths["/notifications/blocking/escalations"]).toBeTruthy();
11
+
12
+ expect(paths["/user/notification-digests/test"]).toBeTruthy();
13
+ expect(paths["/user/notification-digests/analytics"]).toBeTruthy();
14
+ expect(paths["/user/notification-digests/dead-letter"]).toBeTruthy();
15
+ expect(paths["/user/notification-digests/dead-letter/retry"]).toBeTruthy();
16
+
17
+ expect(paths["/admin/plugins"]).toBeTruthy();
18
+ expect(paths["/admin/plugins/{name}"]).toBeTruthy();
19
+ });
20
+});
0
21
▼tests/unit/permissions.test.ts
+162-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+// Mock database
4
+const mockDb = {
5
+ query: {
6
+ repositoryCollaborators: { findFirst: vi.fn() },
7
+ organizationMembers: { findFirst: vi.fn(), findMany: vi.fn() },
8
+ customRoles: { findFirst: vi.fn() },
9
+ organizations: { findFirst: vi.fn() },
10
+ },
11
+};
12
+
13
+vi.mock("@/db", () => ({
14
+ getDatabase: () => mockDb,
15
+ schema: {
16
+ repositoryCollaborators: {},
17
+ organizationMembers: {},
18
+ customRoles: {},
19
+ organizations: {},
20
+ },
21
+}));
22
+
23
+// Now import the module under test
24
+const {
25
+ getRepoPermission,
26
+ getOrgPermission,
27
+ canReadRepo,
28
+ canWriteRepo,
29
+ canAdminRepo,
30
+ canAdminOrg,
31
+} = await import("@/lib/permissions");
32
+
33
+beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ mockDb.query.repositoryCollaborators.findFirst.mockResolvedValue(null);
36
+ mockDb.query.organizationMembers.findFirst.mockResolvedValue(null);
37
+ mockDb.query.organizationMembers.findMany.mockResolvedValue([]);
38
+ mockDb.query.customRoles.findFirst.mockResolvedValue(null);
39
+ mockDb.query.organizations.findFirst.mockResolvedValue(null);
40
+});
41
+
42
+describe("getRepoPermission", () => {
43
+ const publicRepo = {
44
+ id: "r1",
45
+ ownerId: "u1",
46
+ visibility: "public",
47
+ } as any;
48
+
49
+ const privateRepo = {
50
+ id: "r2",
51
+ ownerId: "u1",
52
+ visibility: "private",
53
+ } as any;
54
+
55
+ it("returns admin for site admin", async () => {
56
+ const result = await getRepoPermission("u999", privateRepo, {
57
+ isAdmin: true,
58
+ });
59
+ expect(result).toBe("admin");
60
+ });
61
+
62
+ it("returns admin for repo owner", async () => {
63
+ const result = await getRepoPermission("u1", publicRepo);
64
+ expect(result).toBe("admin");
65
+ });
66
+
67
+ it("returns read for public repo (unauthenticated)", async () => {
68
+ const result = await getRepoPermission(undefined, publicRepo);
69
+ expect(result).toBe("read");
70
+ });
71
+
72
+ it("returns none for private repo (unauthenticated)", async () => {
73
+ const result = await getRepoPermission(undefined, privateRepo);
74
+ expect(result).toBe("none");
75
+ });
76
+
77
+ it("returns collaborator permission when found", async () => {
78
+ mockDb.query.repositoryCollaborators.findFirst.mockResolvedValue({
79
+ permission: "write",
80
+ });
81
+ const result = await getRepoPermission("u2", privateRepo);
82
+ expect(result).toBe("write");
83
+ });
84
+});
85
+
86
+describe("canReadRepo", () => {
87
+ const publicRepo = { id: "r1", ownerId: "u1", visibility: "public" } as any;
88
+ const privateRepo = { id: "r2", ownerId: "u1", visibility: "private" } as any;
89
+
90
+ it("allows read on public repo", async () => {
91
+ expect(await canReadRepo(undefined, publicRepo)).toBe(true);
92
+ });
93
+
94
+ it("denies read on private repo for unauthenticated user", async () => {
95
+ expect(await canReadRepo(undefined, privateRepo)).toBe(false);
96
+ });
97
+
98
+ it("allows read for owner", async () => {
99
+ expect(await canReadRepo("u1", privateRepo)).toBe(true);
100
+ });
101
+
102
+ it("allows read for admin", async () => {
103
+ expect(await canReadRepo("u999", privateRepo, { isAdmin: true })).toBe(
104
+ true,
105
+ );
106
+ });
107
+});
108
+
109
+describe("canWriteRepo", () => {
110
+ const repo = { id: "r1", ownerId: "u1", visibility: "private" } as any;
111
+
112
+ it("allows write for owner", async () => {
113
+ expect(await canWriteRepo("u1", repo)).toBe(true);
114
+ });
115
+
116
+ it("denies write for unauthenticated", async () => {
117
+ expect(await canWriteRepo(undefined, repo)).toBe(false);
118
+ });
119
+
120
+ it("denies write for reader-only collaborator", async () => {
121
+ mockDb.query.repositoryCollaborators.findFirst.mockResolvedValue({
122
+ permission: "read",
123
+ });
124
+ expect(await canWriteRepo("u2", repo)).toBe(false);
125
+ });
126
+
127
+ it("allows write for write collaborator", async () => {
128
+ mockDb.query.repositoryCollaborators.findFirst.mockResolvedValue({
129
+ permission: "write",
130
+ });
131
+ expect(await canWriteRepo("u2", repo)).toBe(true);
132
+ });
133
+});
134
+
135
+describe("canAdminRepo", () => {
136
+ const repo = { id: "r1", ownerId: "u1", visibility: "private" } as any;
137
+
138
+ it("allows admin for owner", async () => {
139
+ expect(await canAdminRepo("u1", repo)).toBe(true);
140
+ });
141
+
142
+ it("allows admin for site admin", async () => {
143
+ expect(await canAdminRepo("u999", repo, { isAdmin: true })).toBe(true);
144
+ });
145
+
146
+ it("denies admin for write collaborator", async () => {
147
+ mockDb.query.repositoryCollaborators.findFirst.mockResolvedValue({
148
+ permission: "write",
149
+ });
150
+ expect(await canAdminRepo("u2", repo)).toBe(false);
151
+ });
152
+});
153
+
154
+describe("canAdminOrg", () => {
155
+ it("allows for site admin", async () => {
156
+ expect(await canAdminOrg("u999", "org1", { isAdmin: true })).toBe(true);
157
+ });
158
+
159
+ it("denies for unauthenticated", async () => {
160
+ expect(await canAdminOrg(undefined, "org1")).toBe(false);
161
+ });
162
+});
0
163
▼tests/unit/runner-registration-token.test.ts
+27-0
1
+import { describe, expect, it } from "vitest";
2
+import {
3
+ buildRunnerRegistrationTokenName,
4
+ createRunnerRegistrationSecret,
5
+ getRunnerRegistrationTokenTtlDays,
6
+ isRunnerRegistrationTokenName,
7
+} from "@/lib/runner-registration-token";
8
+
9
+describe("runner registration tokens", () => {
10
+ it("builds and recognizes registration token names", () => {
11
+ const tokenName = buildRunnerRegistrationTokenName("token-123");
12
+ expect(tokenName).toBe("ACTIONS_RUNNER_TOKEN:token-123");
13
+ expect(isRunnerRegistrationTokenName(tokenName)).toBe(true);
14
+ expect(isRunnerRegistrationTokenName("ACTIONS_RUNNER_TOKEN")).toBe(true);
15
+ expect(isRunnerRegistrationTokenName("OTHER_TOKEN")).toBe(false);
16
+ });
17
+
18
+ it("generates reasonably strong secrets", () => {
19
+ const secret = createRunnerRegistrationSecret();
20
+ expect(secret.length).toBeGreaterThanOrEqual(48);
21
+ });
22
+
23
+ it("uses positive TTL defaults", () => {
24
+ delete process.env.RUNNER_REGISTRATION_TOKEN_TTL_DAYS;
25
+ expect(getRunnerRegistrationTokenTtlDays()).toBeGreaterThan(0);
26
+ });
27
+});
0
28
▼tests/unit/sanitize.test.ts
+113-0
1
+import { escapeHtml, sanitizeHtml } from "@/lib/sanitize";
2
+import { describe, expect, it } from "vitest";
3
+
4
+describe("sanitizeHtml", () => {
5
+ it("removes script tags", () => {
6
+ expect(sanitizeHtml('<script>alert("xss")</script>')).not.toContain(
7
+ "<script",
8
+ );
9
+ });
10
+
11
+ it("removes script tags with attributes", () => {
12
+ expect(sanitizeHtml('<script src="evil.js"></script>')).not.toContain(
13
+ "<script",
14
+ );
15
+ });
16
+
17
+ it("removes event handler attributes", () => {
18
+ const result = sanitizeHtml('<img src="x" onerror="alert(1)" />');
19
+ expect(result).not.toContain("onerror");
20
+ });
21
+
22
+ it("removes onclick handlers", () => {
23
+ const result = sanitizeHtml('<button onclick="steal()">Click</button>');
24
+ expect(result).not.toContain("onclick");
25
+ });
26
+
27
+ it("removes javascript: URIs", () => {
28
+ const result = sanitizeHtml('<a href="javascript:alert(1)">link</a>');
29
+ expect(result).not.toContain("javascript:");
30
+ });
31
+
32
+ it("removes iframe tags", () => {
33
+ const result = sanitizeHtml('<iframe src="http://evil.com"></iframe>');
34
+ expect(result).not.toContain("<iframe");
35
+ });
36
+
37
+ it("removes object tags", () => {
38
+ const result = sanitizeHtml('<object data="malware.swf"></object>');
39
+ expect(result).not.toContain("<object");
40
+ });
41
+
42
+ it("removes embed tags", () => {
43
+ const result = sanitizeHtml('<embed src="malware.swf" />');
44
+ expect(result).not.toContain("<embed");
45
+ });
46
+
47
+ it("removes form tags", () => {
48
+ const result = sanitizeHtml(
49
+ '<form action="http://evil.com"><input /></form>',
50
+ );
51
+ expect(result).not.toContain("<form");
52
+ });
53
+
54
+ it("allows safe tags", () => {
55
+ const safe = "<p>Hello <strong>world</strong></p>";
56
+ const result = sanitizeHtml(safe);
57
+ expect(result).toContain("<p>");
58
+ expect(result).toContain("<strong>");
59
+ });
60
+
61
+ it("handles empty string", () => {
62
+ expect(sanitizeHtml("")).toBe("");
63
+ });
64
+
65
+ it("handles plain text", () => {
66
+ expect(sanitizeHtml("Hello world")).toBe("Hello world");
67
+ });
68
+
69
+ it("handles nested malicious tags", () => {
70
+ const result = sanitizeHtml(
71
+ "<div><script><script>alert(1)</script></script></div>",
72
+ );
73
+ expect(result).not.toContain("<script");
74
+ });
75
+});
76
+
77
+describe("escapeHtml", () => {
78
+ it("escapes ampersands", () => {
79
+ expect(escapeHtml("foo & bar")).toBe("foo &amp; bar");
80
+ });
81
+
82
+ it("escapes less-than", () => {
83
+ expect(escapeHtml("a < b")).toBe("a &lt; b");
84
+ });
85
+
86
+ it("escapes greater-than", () => {
87
+ expect(escapeHtml("a > b")).toBe("a &gt; b");
88
+ });
89
+
90
+ it("escapes double quotes", () => {
91
+ expect(escapeHtml('say "hello"')).toBe("say &quot;hello&quot;");
92
+ });
93
+
94
+ it("escapes single quotes", () => {
95
+ const result = escapeHtml("it's");
96
+ expect(result).toContain("&#");
97
+ });
98
+
99
+ it("handles empty string", () => {
100
+ expect(escapeHtml("")).toBe("");
101
+ });
102
+
103
+ it("handles already safe text", () => {
104
+ expect(escapeHtml("Hello world")).toBe("Hello world");
105
+ });
106
+
107
+ it("escapes all special chars combined", () => {
108
+ const result = escapeHtml("<script>\"alert('xss')&</script>");
109
+ expect(result).not.toContain("<");
110
+ expect(result).not.toContain(">");
111
+ expect(result).toContain("&amp;");
112
+ });
113
+});
0
114
▼tests/unit/security-policies.test.ts
+37-0
1
+import { describe, expect, it } from "vitest";
2
+import {
3
+ evaluateLicensePolicy,
4
+ evaluateSecretPolicy,
5
+} from "@/lib/security-policies";
6
+
7
+const basePolicy = {
8
+ enforcementMode: "block" as const,
9
+ secretBlockedTypes: ["aws_access_key"],
10
+ secretMinSeverity: "HIGH" as const,
11
+ licenseAllowedTypes: ["permissive"],
12
+ licenseBlockedLicenses: ["GPL-3.0"],
13
+ isEnabled: true,
14
+};
15
+
16
+describe("security policy evaluation", () => {
17
+ it("flags blocked secret type", () => {
18
+ const result = evaluateSecretPolicy(basePolicy, "aws_access_key", "LOW");
19
+ expect(result.violated).toBe(true);
20
+ });
21
+
22
+ it("flags secret by severity threshold", () => {
23
+ const result = evaluateSecretPolicy(basePolicy, "generic", "CRITICAL");
24
+ expect(result.violated).toBe(true);
25
+ });
26
+
27
+ it("flags blocked license name", () => {
28
+ const result = evaluateLicensePolicy(basePolicy, "copyleft", "LICENSE-GPL-3.0", "License: GPL-3.0");
29
+ expect(result.violated).toBe(true);
30
+ });
31
+
32
+ it("flags disallowed license type", () => {
33
+ const result = evaluateLicensePolicy(basePolicy, "copyleft", "LICENSE-MPL-2.0", "License: MPL-2.0");
34
+ expect(result.violated).toBe(true);
35
+ });
36
+});
37
+
0
38
▼tests/unit/suggested-changes.test.ts
+68-0
1
+import { parseSuggestionFromComment } from "@/lib/suggested-changes";
2
+import { describe, expect, it } from "vitest";
3
+
4
+describe("parseSuggestionFromComment", () => {
5
+ it("extracts suggestion from a comment body", () => {
6
+ const body = `
7
+Some text before.
8
+
9
+\`\`\`suggestion
10
+const x = 42;
11
+\`\`\`
12
+
13
+Some text after.
14
+ `.trim();
15
+
16
+ const result = parseSuggestionFromComment(body);
17
+ expect(result).toBeDefined();
18
+ expect(result).toContain("const x = 42;");
19
+ });
20
+
21
+ it("returns null for comment without suggestion", () => {
22
+ const body = "This is a regular comment without any suggestion block.";
23
+ const result = parseSuggestionFromComment(body);
24
+ expect(result).toBeNull();
25
+ });
26
+
27
+ it("returns null for empty string", () => {
28
+ expect(parseSuggestionFromComment("")).toBeNull();
29
+ });
30
+
31
+ it("extracts multi-line suggestion", () => {
32
+ const body = `
33
+Review comment.
34
+
35
+\`\`\`suggestion
36
+function hello() {
37
+ return "world";
38
+}
39
+\`\`\`
40
+ `.trim();
41
+
42
+ const result = parseSuggestionFromComment(body);
43
+ expect(result).toBeDefined();
44
+ expect(result).toContain("function hello()");
45
+ expect(result).toContain('return "world"');
46
+ });
47
+
48
+ it("handles suggestion at start of body", () => {
49
+ const body = `\`\`\`suggestion
50
+fix this
51
+\`\`\``;
52
+
53
+ const result = parseSuggestionFromComment(body);
54
+ expect(result).toBeDefined();
55
+ expect(result).toContain("fix this");
56
+ });
57
+
58
+ it("does not confuse regular code blocks", () => {
59
+ const body = `
60
+\`\`\`javascript
61
+const x = 1;
62
+\`\`\`
63
+ `.trim();
64
+
65
+ const result = parseSuggestionFromComment(body);
66
+ expect(result).toBeNull();
67
+ });
68
+});
0
69
▼tests/unit/url-validator.test.ts
+100-0
1
+import { isAllowedWebhookUrlSync } from "@/lib/url-validator";
2
+import { describe, expect, it } from "vitest";
3
+
4
+describe("isAllowedWebhookUrlSync", () => {
5
+ describe("blocks private/reserved IPs", () => {
6
+ it("blocks localhost", () => {
7
+ expect(isAllowedWebhookUrlSync("http://localhost/hook")).toBe(false);
8
+ });
9
+
10
+ it("blocks localhost with port", () => {
11
+ expect(isAllowedWebhookUrlSync("http://localhost:8080/hook")).toBe(false);
12
+ });
13
+
14
+ it("blocks 127.0.0.1", () => {
15
+ expect(isAllowedWebhookUrlSync("http://127.0.0.1/hook")).toBe(false);
16
+ });
17
+
18
+ it("blocks 10.x.x.x range", () => {
19
+ expect(isAllowedWebhookUrlSync("http://10.0.0.1/hook")).toBe(false);
20
+ });
21
+
22
+ it("blocks 192.168.x.x range", () => {
23
+ expect(isAllowedWebhookUrlSync("http://192.168.1.1/hook")).toBe(false);
24
+ });
25
+
26
+ it("blocks 172.16.x.x range", () => {
27
+ expect(isAllowedWebhookUrlSync("http://172.16.0.1/hook")).toBe(false);
28
+ });
29
+
30
+ it("blocks 169.254.x.x metadata range", () => {
31
+ expect(
32
+ isAllowedWebhookUrlSync("http://169.254.169.254/latest/meta-data"),
33
+ ).toBe(false);
34
+ });
35
+
36
+ it("blocks 0.0.0.0", () => {
37
+ expect(isAllowedWebhookUrlSync("http://0.0.0.0/hook")).toBe(false);
38
+ });
39
+
40
+ it("blocks [::1] (IPv6 loopback)", () => {
41
+ expect(isAllowedWebhookUrlSync("http://[::1]/hook")).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe("blocks non-http(s) schemes", () => {
46
+ it("blocks ftp scheme", () => {
47
+ expect(isAllowedWebhookUrlSync("ftp://example.com/hook")).toBe(false);
48
+ });
49
+
50
+ it("blocks file scheme", () => {
51
+ expect(isAllowedWebhookUrlSync("file:///etc/passwd")).toBe(false);
52
+ });
53
+
54
+ it("blocks javascript scheme", () => {
55
+ expect(isAllowedWebhookUrlSync("javascript:alert(1)")).toBe(false);
56
+ });
57
+
58
+ it("blocks data scheme", () => {
59
+ expect(
60
+ isAllowedWebhookUrlSync("data:text/html,<script>alert(1)</script>"),
61
+ ).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe("allows valid public URLs", () => {
66
+ it("allows https public URL", () => {
67
+ expect(isAllowedWebhookUrlSync("https://example.com/webhook")).toBe(true);
68
+ });
69
+
70
+ it("allows http public URL", () => {
71
+ expect(isAllowedWebhookUrlSync("http://example.com/webhook")).toBe(true);
72
+ });
73
+
74
+ it("allows public IP", () => {
75
+ expect(isAllowedWebhookUrlSync("http://8.8.8.8/hook")).toBe(true);
76
+ });
77
+
78
+ it("allows URL with port", () => {
79
+ expect(
80
+ isAllowedWebhookUrlSync("https://hooks.example.com:9000/webhook"),
81
+ ).toBe(true);
82
+ });
83
+
84
+ it("allows URL with path", () => {
85
+ expect(
86
+ isAllowedWebhookUrlSync("https://api.example.com/v1/hooks/receive"),
87
+ ).toBe(true);
88
+ });
89
+ });
90
+
91
+ describe("edge cases", () => {
92
+ it("rejects empty string", () => {
93
+ expect(isAllowedWebhookUrlSync("")).toBe(false);
94
+ });
95
+
96
+ it("rejects invalid URL", () => {
97
+ expect(isAllowedWebhookUrlSync("not-a-url")).toBe(false);
98
+ });
99
+ });
100
+});
0
101
▼tests/unit/validation.test.ts
+268-0
1
+import {
2
+ BranchProtectionSchema,
3
+ CreateIssueSchema,
4
+ CreatePullRequestSchema,
5
+ CreateRepositorySchema,
6
+ GeneralConfigSchema,
7
+ RegisterUserSchema,
8
+ StorageConfigSchema,
9
+ WebhookConfigSchema,
10
+} from "@/lib/validation";
11
+import { describe, expect, it } from "vitest";
12
+
13
+describe("RegisterUserSchema", () => {
14
+ it("accepts a valid registration", () => {
15
+ const result = RegisterUserSchema.safeParse({
16
+ username: "testuser",
17
+ email: "[email protected]",
18
+ password: "StrongP@ss123",
19
+ displayName: "Test User",
20
+ });
21
+ expect(result.success).toBe(true);
22
+ });
23
+
24
+ it("rejects missing username", () => {
25
+ const result = RegisterUserSchema.safeParse({
26
+ email: "[email protected]",
27
+ password: "StrongP@ss123",
28
+ });
29
+ expect(result.success).toBe(false);
30
+ });
31
+
32
+ it("rejects invalid email", () => {
33
+ const result = RegisterUserSchema.safeParse({
34
+ username: "testuser",
35
+ email: "notanemail",
36
+ password: "StrongP@ss123",
37
+ });
38
+ expect(result.success).toBe(false);
39
+ });
40
+
41
+ it("rejects short password", () => {
42
+ const result = RegisterUserSchema.safeParse({
43
+ username: "testuser",
44
+ email: "[email protected]",
45
+ password: "abc",
46
+ });
47
+ expect(result.success).toBe(false);
48
+ });
49
+
50
+ it("rejects empty username", () => {
51
+ const result = RegisterUserSchema.safeParse({
52
+ username: "",
53
+ email: "[email protected]",
54
+ password: "StrongP@ss123",
55
+ });
56
+ expect(result.success).toBe(false);
57
+ });
58
+});
59
+
60
+describe("CreateRepositorySchema", () => {
61
+ it("accepts minimal valid repo", () => {
62
+ const result = CreateRepositorySchema.safeParse({
63
+ name: "my-repo",
64
+ });
65
+ expect(result.success).toBe(true);
66
+ });
67
+
68
+ it("accepts full valid repo", () => {
69
+ const result = CreateRepositorySchema.safeParse({
70
+ name: "my-repo",
71
+ description: "A test repository",
72
+ visibility: "public",
73
+ defaultBranch: "main",
74
+ });
75
+ expect(result.success).toBe(true);
76
+ });
77
+
78
+ it("rejects missing name", () => {
79
+ const result = CreateRepositorySchema.safeParse({
80
+ description: "A test repository",
81
+ });
82
+ expect(result.success).toBe(false);
83
+ });
84
+
85
+ it("rejects empty name", () => {
86
+ const result = CreateRepositorySchema.safeParse({ name: "" });
87
+ expect(result.success).toBe(false);
88
+ });
89
+
90
+ it("rejects invalid visibility", () => {
91
+ const result = CreateRepositorySchema.safeParse({
92
+ name: "my-repo",
93
+ visibility: "invalid-mode",
94
+ });
95
+ expect(result.success).toBe(false);
96
+ });
97
+});
98
+
99
+describe("CreateIssueSchema", () => {
100
+ it("accepts a valid issue", () => {
101
+ const result = CreateIssueSchema.safeParse({
102
+ title: "Bug report",
103
+ body: "Something is broken",
104
+ });
105
+ expect(result.success).toBe(true);
106
+ });
107
+
108
+ it("rejects empty title", () => {
109
+ const result = CreateIssueSchema.safeParse({
110
+ title: "",
111
+ body: "Something is broken",
112
+ });
113
+ expect(result.success).toBe(false);
114
+ });
115
+
116
+ it("rejects missing title", () => {
117
+ const result = CreateIssueSchema.safeParse({
118
+ body: "Something is broken",
119
+ });
120
+ expect(result.success).toBe(false);
121
+ });
122
+
123
+ it("sanitizes HTML in body", () => {
124
+ const result = CreateIssueSchema.safeParse({
125
+ title: "XSS test",
126
+ body: '<script>alert("xss")</script>normal text',
127
+ });
128
+ expect(result.success).toBe(true);
129
+ if (result.success) {
130
+ expect(result.data.body).not.toContain("<script");
131
+ }
132
+ });
133
+});
134
+
135
+describe("CreatePullRequestSchema", () => {
136
+ it("accepts a valid pull request", () => {
137
+ const result = CreatePullRequestSchema.safeParse({
138
+ title: "New feature",
139
+ body: "Adds cool stuff",
140
+ head: "feature-branch",
141
+ base: "main",
142
+ });
143
+ expect(result.success).toBe(true);
144
+ });
145
+
146
+ it("rejects missing head branch", () => {
147
+ const result = CreatePullRequestSchema.safeParse({
148
+ title: "New feature",
149
+ base: "main",
150
+ });
151
+ expect(result.success).toBe(false);
152
+ });
153
+
154
+ it("rejects missing base branch", () => {
155
+ const result = CreatePullRequestSchema.safeParse({
156
+ title: "New feature",
157
+ head: "feature-branch",
158
+ });
159
+ expect(result.success).toBe(false);
160
+ });
161
+
162
+ it("rejects missing title", () => {
163
+ const result = CreatePullRequestSchema.safeParse({
164
+ head: "feature",
165
+ base: "main",
166
+ });
167
+ expect(result.success).toBe(false);
168
+ });
169
+});
170
+
171
+describe("BranchProtectionSchema", () => {
172
+ it("accepts valid protection rules", () => {
173
+ const result = BranchProtectionSchema.safeParse({
174
+ pattern: "main",
175
+ active: true,
176
+ requiresPr: true,
177
+ requiredApprovals: 2,
178
+ });
179
+ expect(result.success).toBe(true);
180
+ });
181
+
182
+ it("accepts minimal pattern", () => {
183
+ const result = BranchProtectionSchema.safeParse({
184
+ pattern: "main",
185
+ });
186
+ expect(result.success).toBe(true);
187
+ });
188
+
189
+ it("rejects missing pattern", () => {
190
+ const result = BranchProtectionSchema.safeParse({
191
+ active: true,
192
+ });
193
+ expect(result.success).toBe(false);
194
+ });
195
+
196
+ it("rejects negative approvals", () => {
197
+ const result = BranchProtectionSchema.safeParse({
198
+ pattern: "main",
199
+ requiredApprovals: -1,
200
+ });
201
+ expect(result.success).toBe(false);
202
+ });
203
+});
204
+
205
+describe("WebhookConfigSchema", () => {
206
+ it("accepts valid webhook config", () => {
207
+ const result = WebhookConfigSchema.safeParse({
208
+ url: "https://example.com/webhook",
209
+ events: ["push", "pull_request"],
210
+ secret: "mysecret",
211
+ active: true,
212
+ });
213
+ expect(result.success).toBe(true);
214
+ });
215
+
216
+ it("rejects missing url", () => {
217
+ const result = WebhookConfigSchema.safeParse({
218
+ events: ["push"],
219
+ });
220
+ expect(result.success).toBe(false);
221
+ });
222
+
223
+ it("rejects private IP urls (SSRF protection)", () => {
224
+ const result = WebhookConfigSchema.safeParse({
225
+ url: "http://127.0.0.1:8080/hook",
226
+ events: ["push"],
227
+ });
228
+ // Should fail SSRF refine
229
+ expect(result.success).toBe(false);
230
+ });
231
+
232
+ it("rejects localhost urls", () => {
233
+ const result = WebhookConfigSchema.safeParse({
234
+ url: "http://localhost:3000/hook",
235
+ events: ["push"],
236
+ });
237
+ expect(result.success).toBe(false);
238
+ });
239
+});
240
+
241
+describe("StorageConfigSchema", () => {
242
+ it("accepts local storage config", () => {
243
+ const result = StorageConfigSchema.safeParse({
244
+ type: "local",
245
+ basePath: "/data/storage",
246
+ });
247
+ expect(result.success).toBe(true);
248
+ });
249
+
250
+ it("accepts s3 storage config", () => {
251
+ const result = StorageConfigSchema.safeParse({
252
+ type: "s3",
253
+ bucket: "my-bucket",
254
+ region: "us-east-1",
255
+ });
256
+ expect(result.success).toBe(true);
257
+ });
258
+});
259
+
260
+describe("GeneralConfigSchema", () => {
261
+ it("accepts valid general config", () => {
262
+ const result = GeneralConfigSchema.safeParse({
263
+ siteName: "My CodeHub",
264
+ allowSignups: true,
265
+ });
266
+ expect(result.success).toBe(true);
267
+ });
268
+});
0
269
▼tests/unit/workflow-secret-crypto.test.ts
+25-0
1
+import { describe, expect, it } from "vitest";
2
+import {
3
+ decryptWorkflowSecret,
4
+ encryptWorkflowSecret,
5
+ isEncryptedWorkflowSecret,
6
+} from "@/lib/workflow-secret-crypto";
7
+
8
+describe("workflow secret crypto", () => {
9
+ it("encrypts and decrypts values", () => {
10
+ process.env.WORKFLOW_SECRET_ENCRYPTION_KEY =
11
+ "workflow-secret-encryption-key-for-tests-32+";
12
+ const plain = "runner-registration-token-123";
13
+ const encrypted = encryptWorkflowSecret(plain);
14
+
15
+ expect(isEncryptedWorkflowSecret(encrypted)).toBe(true);
16
+ expect(encrypted).not.toContain(plain);
17
+ expect(decryptWorkflowSecret(encrypted)).toBe(plain);
18
+ });
19
+
20
+ it("supports legacy plaintext values for backward compatibility", () => {
21
+ const plain = "legacy-plaintext-secret";
22
+ expect(isEncryptedWorkflowSecret(plain)).toBe(false);
23
+ expect(decryptWorkflowSecret(plain)).toBe(plain);
24
+ });
25
+});
0
26