Skip to content

Commit ffd10b6

Browse files
Merge pull request #516 from microsoft/copilot/plan-implement-next-work
Add integration tests for 5 previously untested API routes and improve documentation
2 parents fe7090d + e852606 commit ffd10b6

File tree

20 files changed

+2863
-10
lines changed

20 files changed

+2863
-10
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ npm run typecheck
9696
# 3. Lint — zero warnings (warnings are errors)
9797
npm run lint -w @acroyoga/web
9898

99-
# 4. Run all tests — tokens (20) → shared-ui (85) → web (580+)
99+
# 4. Run all tests — tokens (20) → shared-ui (85) → web (630+)
100100
npm run test
101101

102102
# 5. Production build — must succeed

README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ This is an **npm workspaces monorepo** with shared packages:
5858
5959
├── specs/ # Spec-Kit feature specifications
6060
│ ├── constitution.md # Architectural principles (v1.5.0)
61-
│ └── 001–012/ # Feature specs with plans, tasks, contracts
61+
│ └── 001–016/ # Feature specs with plans, tasks, contracts
6262
6363
└── .agent.md # UI Expert agent configuration
6464
```
@@ -93,8 +93,28 @@ Each feature is developed from a full spec (user scenarios, data model, API cont
9393
| 011b | [Entra External ID](specs/011-entra-external-id/) | P1 | Implemented |
9494
| 012 | [Managed Identity Deploy](specs/012-managed-identity-deploy/) | P2 | Implemented |
9595
| 013 | [Platform Improvements](specs/013-platform-improvements/) | P2 | Complete |
96+
| 014 | [Internationalisation](specs/014-internationalisation/) | P1 | Planned |
97+
| 015 | [Background Jobs & Notifications](specs/015-background-jobs-notifications/) | P1 | Planned |
98+
| 016 | [Mobile App (Expo/React Native)](specs/016-mobile-app/) | P1 | Planned |
9699

97-
> Specs 006 and 007 are internal infrastructure (security hardening, dev tooling, UI pages). Spec 008 mobile phases are deferred. Specs 011–012 cover Azure production deployment with Managed Identity and Entra External ID social login. Spec 013 added CONTRIBUTING.md, API reference docs, database/testing docs, Playwright E2E tests, and triaged all remaining tasks across specs 001–010.
100+
> Specs 006 and 007 are internal infrastructure (security hardening, dev tooling, UI pages). Specs 011–012 cover Azure production deployment with Managed Identity and Entra External ID social login. Spec 013 added CONTRIBUTING.md, API reference docs, database/testing docs, Playwright E2E tests, and triaged all remaining tasks across specs 001–010. Specs 014–016 are the next wave of features — i18n (Constitution VIII), background jobs & notifications (Constitution X), and native mobile apps completing the cross-platform vision from Spec 008.
101+
102+
## Roadmap
103+
104+
The platform is **feature-complete for web**. All P0 and P1 features are implemented across 13 specs (001–013). The next wave of work is captured in three new specs:
105+
106+
| Priority | Spec | Scope | Tasks |
107+
|----------|------|-------|-------|
108+
| **Next** | [014 — Internationalisation](specs/014-internationalisation/) | `next-intl` integration, string extraction (~200+ strings), `Intl.DateTimeFormat` migration, RTL support, locale switcher, CI enforcement | 44 tasks, 6 phases |
109+
| **Next** | [015 — Background Jobs & Notifications](specs/015-background-jobs-notifications/) | `pg-boss` job queue, in-app notifications, email delivery (Azure Communication Services), notification preferences, scheduled jobs (review reminders, cert-expiry) | 45 tasks, 7 phases |
110+
| **Future** | [016 — Mobile App](specs/016-mobile-app/) | Expo/React Native, 5-tab navigation, JWT auth, TanStack Query + MMKV offline, push notifications. Completes Spec 008's deferred mobile phases | 63 tasks, 10 phases |
111+
112+
Additional deferred work (lower priority, not yet specced):
113+
- **UI Component Extraction** — 21 presentational component wrappers (Spec 001 deferred tasks)
114+
- **Performance Optimization** — Image optimization, lazy loading, skeleton loaders
115+
- **WCAG Manual Audit** — Keyboard navigation and screen reader testing beyond axe-core automation
116+
- **SEO & Social Sharing** — OG metadata generation, event sharing cards
117+
- **Geolocation & Heatmap** — "Near Me" button and event density heatmap (Spec 010 deferred)
98118

99119
## Documentation
100120

@@ -154,7 +174,7 @@ npm run lint # ESLint (includes jsx-a11y)
154174
```bash
155175
npm run test -w @acroyoga/tokens # Run token pipeline tests (20 tests)
156176
npm run test -w @acroyoga/shared-ui # Run shared-ui component tests (85 tests)
157-
npm run test -w @acroyoga/web # Run web integration tests (339 tests)
177+
npm run test -w @acroyoga/web # Run web integration tests (630+ tests)
158178
npm run tokens:build # Rebuild design tokens
159179
npm run tokens:watch # Watch token source & rebuild on change
160180
```
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* Integration tests for GET /api/account/exports/[id]/download — GDPR export download
3+
*
4+
* Tests:
5+
* - 401 for unauthenticated requests
6+
* - 404 for non-existent export
7+
* - 400 for incomplete export
8+
* - 200 with JSON attachment for completed export
9+
* - Ownership check: user cannot download another user's export
10+
*
11+
* Constitution II (Test-First), III (Privacy & Data Protection)
12+
*/
13+
14+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
15+
import { PGlite } from "@electric-sql/pglite";
16+
import { setTestDb, clearTestDb } from "@/lib/db/client";
17+
import fs from "fs";
18+
import path from "path";
19+
20+
// Mock getServerSession
21+
vi.mock("@/lib/auth/session", () => ({
22+
getServerSession: vi.fn(),
23+
}));
24+
25+
// Mock GDPR export to avoid cross-module complications
26+
vi.mock("@/lib/gdpr/export", () => ({
27+
exportUserData: vi.fn().mockResolvedValue({}),
28+
}));
29+
30+
import { getServerSession } from "@/lib/auth/session";
31+
const mockGetServerSession = vi.mocked(getServerSession);
32+
33+
let pg: PGlite;
34+
35+
async function applyMigrations(d: PGlite) {
36+
const migrationsDir = path.resolve(__dirname, "../../../src/db/migrations");
37+
const files = fs
38+
.readdirSync(migrationsDir)
39+
.filter((f) => f.endsWith(".sql"))
40+
.sort();
41+
for (const file of files) {
42+
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8");
43+
await d.exec(sql);
44+
}
45+
}
46+
47+
async function createUser(d: PGlite, email: string): Promise<string> {
48+
const result = await d.query<{ id: string }>(
49+
"INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id",
50+
[email, email.split("@")[0]],
51+
);
52+
return result.rows[0].id;
53+
}
54+
55+
describe("GET /api/account/exports/[id]/download", () => {
56+
let userId: string;
57+
let otherUserId: string;
58+
59+
beforeEach(async () => {
60+
pg = new PGlite();
61+
await applyMigrations(pg);
62+
setTestDb(pg);
63+
64+
userId = await createUser(pg, "user@test.com");
65+
otherUserId = await createUser(pg, "other@test.com");
66+
});
67+
68+
afterEach(async () => {
69+
clearTestDb();
70+
vi.resetAllMocks();
71+
await pg.close();
72+
});
73+
74+
async function callDownload(
75+
exportId: string,
76+
sessionOverride?: { userId: string } | null,
77+
) {
78+
if (sessionOverride === null) {
79+
mockGetServerSession.mockResolvedValue(null);
80+
} else if (sessionOverride) {
81+
mockGetServerSession.mockResolvedValue(sessionOverride);
82+
}
83+
84+
const { GET } = await import(
85+
"@/app/api/account/exports/[id]/download/route"
86+
);
87+
const { NextRequest } = await import("next/server");
88+
const request = new NextRequest(
89+
`http://localhost/api/account/exports/${exportId}/download`,
90+
);
91+
return GET(request, { params: Promise.resolve({ id: exportId }) });
92+
}
93+
94+
it("returns 401 for unauthenticated request", async () => {
95+
const response = await callDownload("some-id", null);
96+
expect(response.status).toBe(401);
97+
});
98+
99+
it("returns 404 for non-existent export", async () => {
100+
const response = await callDownload(
101+
"00000000-0000-0000-0000-000000000000",
102+
{ userId },
103+
);
104+
expect(response.status).toBe(404);
105+
106+
const body = await response.json();
107+
expect(body.error).toContain("not found");
108+
});
109+
110+
it("returns 404 when accessing another user's export (ownership check)", async () => {
111+
// Create export for userId
112+
const exportResult = await pg.query<{ id: string }>(
113+
"INSERT INTO data_exports (user_id, status) VALUES ($1, 'completed') RETURNING id",
114+
[userId],
115+
);
116+
const exportId = exportResult.rows[0].id;
117+
118+
// Try to access as otherUser
119+
const response = await callDownload(exportId, { userId: otherUserId });
120+
expect(response.status).toBe(404);
121+
});
122+
123+
it("returns 400 when export is not yet completed", async () => {
124+
const exportResult = await pg.query<{ id: string }>(
125+
"INSERT INTO data_exports (user_id, status) VALUES ($1, 'pending') RETURNING id",
126+
[userId],
127+
);
128+
const exportId = exportResult.rows[0].id;
129+
130+
const response = await callDownload(exportId, { userId });
131+
expect(response.status).toBe(400);
132+
133+
const body = await response.json();
134+
expect(body.error).toContain("not yet completed");
135+
});
136+
137+
it("returns 200 with JSON attachment for completed export", async () => {
138+
const exportResult = await pg.query<{ id: string }>(
139+
"INSERT INTO data_exports (user_id, status) VALUES ($1, 'completed') RETURNING id",
140+
[userId],
141+
);
142+
const exportId = exportResult.rows[0].id;
143+
144+
const response = await callDownload(exportId, { userId });
145+
expect(response.status).toBe(200);
146+
147+
expect(response.headers.get("Content-Type")).toBe("application/json");
148+
expect(response.headers.get("Content-Disposition")).toContain(
149+
`data-export-${exportId}.json`,
150+
);
151+
152+
const text = await response.text();
153+
const data = JSON.parse(text);
154+
// Should be a valid export schema with expected keys
155+
expect(data).toHaveProperty("rsvps");
156+
expect(data).toHaveProperty("follows");
157+
expect(data).toHaveProperty("blocks");
158+
expect(data).toHaveProperty("mutes");
159+
});
160+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Integration tests for GET /api/payments/callback — Stripe OAuth callback
3+
*
4+
* Tests redirect behavior for:
5+
* - Error from Stripe → redirect with error description
6+
* - Missing code/state → redirect with missing_params error
7+
* - Successful callback → redirect with success status
8+
* - handleCallback failure → redirect with connection_failed error
9+
*
10+
* Constitution II (Test-First), XII (Financial Integrity)
11+
*/
12+
13+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
14+
import { NextRequest } from "next/server";
15+
16+
// Mock handleCallback to avoid real Stripe API calls
17+
vi.mock("@/lib/payments/stripe-connect", () => ({
18+
handleCallback: vi.fn(),
19+
}));
20+
21+
import { handleCallback } from "@/lib/payments/stripe-connect";
22+
const mockHandleCallback = vi.mocked(handleCallback);
23+
24+
describe("GET /api/payments/callback", () => {
25+
beforeEach(() => {
26+
vi.resetModules();
27+
});
28+
29+
afterEach(() => {
30+
vi.resetAllMocks();
31+
});
32+
33+
async function callCallback(searchParams: Record<string, string>) {
34+
const url = new URL("http://localhost/api/payments/callback");
35+
for (const [key, value] of Object.entries(searchParams)) {
36+
url.searchParams.set(key, value);
37+
}
38+
const request = new NextRequest(url);
39+
const { GET } = await import("@/app/api/payments/callback/route");
40+
return GET(request);
41+
}
42+
43+
it("redirects with error description when Stripe returns an error", async () => {
44+
const response = await callCallback({
45+
error: "access_denied",
46+
error_description: "User denied access",
47+
});
48+
49+
expect(response.status).toBe(307);
50+
const location = response.headers.get("Location")!;
51+
expect(location).toContain("/settings/creator");
52+
expect(location).toContain("error=User%20denied%20access");
53+
});
54+
55+
it("uses default error description when none provided", async () => {
56+
const response = await callCallback({
57+
error: "access_denied",
58+
});
59+
60+
expect(response.status).toBe(307);
61+
const location = response.headers.get("Location")!;
62+
expect(location).toContain("error=Unknown%20error");
63+
});
64+
65+
it("redirects with missing_params when code is absent", async () => {
66+
const response = await callCallback({
67+
state: "user-123",
68+
});
69+
70+
expect(response.status).toBe(307);
71+
const location = response.headers.get("Location")!;
72+
expect(location).toContain("error=missing_params");
73+
});
74+
75+
it("redirects with missing_params when state is absent", async () => {
76+
const response = await callCallback({
77+
code: "auth_code_123",
78+
});
79+
80+
expect(response.status).toBe(307);
81+
const location = response.headers.get("Location")!;
82+
expect(location).toContain("error=missing_params");
83+
});
84+
85+
it("redirects with success when handleCallback succeeds", async () => {
86+
mockHandleCallback.mockResolvedValue({
87+
id: "pay_1",
88+
userId: "user-123",
89+
stripeAccountId: "acct_test",
90+
onboardingComplete: false,
91+
connectedAt: new Date().toISOString(),
92+
disconnectedAt: null,
93+
});
94+
95+
const response = await callCallback({
96+
code: "auth_code_123",
97+
state: "user-123",
98+
});
99+
100+
expect(response.status).toBe(307);
101+
const location = response.headers.get("Location")!;
102+
expect(location).toContain("status=success");
103+
expect(mockHandleCallback).toHaveBeenCalledWith("auth_code_123", "user-123");
104+
});
105+
106+
it("redirects with connection_failed when handleCallback throws", async () => {
107+
mockHandleCallback.mockRejectedValue(new Error("Stripe API error"));
108+
109+
const response = await callCallback({
110+
code: "bad_code",
111+
state: "user-123",
112+
});
113+
114+
expect(response.status).toBe(307);
115+
const location = response.headers.get("Location")!;
116+
expect(location).toContain("error=connection_failed");
117+
});
118+
});

0 commit comments

Comments
 (0)