Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ npm run typecheck
# 3. Lint — zero warnings (warnings are errors)
npm run lint -w @acroyoga/web

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

# 5. Production build — must succeed
Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ This is an **npm workspaces monorepo** with shared packages:
├── specs/ # Spec-Kit feature specifications
│ ├── constitution.md # Architectural principles (v1.5.0)
│ └── 001–012/ # Feature specs with plans, tasks, contracts
│ └── 001–016/ # Feature specs with plans, tasks, contracts
└── .agent.md # UI Expert agent configuration
```
Expand Down Expand Up @@ -93,8 +93,28 @@ Each feature is developed from a full spec (user scenarios, data model, API cont
| 011b | [Entra External ID](specs/011-entra-external-id/) | P1 | Implemented |
| 012 | [Managed Identity Deploy](specs/012-managed-identity-deploy/) | P2 | Implemented |
| 013 | [Platform Improvements](specs/013-platform-improvements/) | P2 | Complete |
| 014 | [Internationalisation](specs/014-internationalisation/) | P1 | Planned |
| 015 | [Background Jobs & Notifications](specs/015-background-jobs-notifications/) | P1 | Planned |
| 016 | [Mobile App (Expo/React Native)](specs/016-mobile-app/) | P1 | Planned |

> 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.
> 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.

## Roadmap

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:

| Priority | Spec | Scope | Tasks |
|----------|------|-------|-------|
| **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 |
| **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 |
| **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 |

Additional deferred work (lower priority, not yet specced):
- **UI Component Extraction** — 21 presentational component wrappers (Spec 001 deferred tasks)
- **Performance Optimization** — Image optimization, lazy loading, skeleton loaders
- **WCAG Manual Audit** — Keyboard navigation and screen reader testing beyond axe-core automation
- **SEO & Social Sharing** — OG metadata generation, event sharing cards
- **Geolocation & Heatmap** — "Near Me" button and event density heatmap (Spec 010 deferred)

## Documentation

Expand Down Expand Up @@ -154,7 +174,7 @@ npm run lint # ESLint (includes jsx-a11y)
```bash
npm run test -w @acroyoga/tokens # Run token pipeline tests (20 tests)
npm run test -w @acroyoga/shared-ui # Run shared-ui component tests (85 tests)
npm run test -w @acroyoga/web # Run web integration tests (339 tests)
npm run test -w @acroyoga/web # Run web integration tests (630+ tests)
npm run tokens:build # Rebuild design tokens
npm run tokens:watch # Watch token source & rebuild on change
```
Expand Down
160 changes: 160 additions & 0 deletions apps/web/tests/integration/account/export-download.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Integration tests for GET /api/account/exports/[id]/download — GDPR export download
*
* Tests:
* - 401 for unauthenticated requests
* - 404 for non-existent export
* - 400 for incomplete export
* - 200 with JSON attachment for completed export
* - Ownership check: user cannot download another user's export
*
* Constitution II (Test-First), III (Privacy & Data Protection)
*/

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { PGlite } from "@electric-sql/pglite";
import { setTestDb, clearTestDb } from "@/lib/db/client";
import fs from "fs";
import path from "path";

// Mock getServerSession
vi.mock("@/lib/auth/session", () => ({
getServerSession: vi.fn(),
}));

// Mock GDPR export to avoid cross-module complications
vi.mock("@/lib/gdpr/export", () => ({
exportUserData: vi.fn().mockResolvedValue({}),
}));

import { getServerSession } from "@/lib/auth/session";
const mockGetServerSession = vi.mocked(getServerSession);

let pg: PGlite;

async function applyMigrations(d: PGlite) {
const migrationsDir = path.resolve(__dirname, "../../../src/db/migrations");
const files = fs
.readdirSync(migrationsDir)
.filter((f) => f.endsWith(".sql"))
.sort();
for (const file of files) {
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8");
await d.exec(sql);
}
}

async function createUser(d: PGlite, email: string): Promise<string> {
const result = await d.query<{ id: string }>(
"INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id",
[email, email.split("@")[0]],
);
return result.rows[0].id;
}

describe("GET /api/account/exports/[id]/download", () => {
let userId: string;
let otherUserId: string;

beforeEach(async () => {
pg = new PGlite();
await applyMigrations(pg);
setTestDb(pg);

userId = await createUser(pg, "user@test.com");
otherUserId = await createUser(pg, "other@test.com");
});

afterEach(async () => {
clearTestDb();
vi.resetAllMocks();
await pg.close();
});

async function callDownload(
exportId: string,
sessionOverride?: { userId: string } | null,
) {
if (sessionOverride === null) {
mockGetServerSession.mockResolvedValue(null);
} else if (sessionOverride) {
mockGetServerSession.mockResolvedValue(sessionOverride);
}

const { GET } = await import(
"@/app/api/account/exports/[id]/download/route"
);
const { NextRequest } = await import("next/server");
const request = new NextRequest(
`http://localhost/api/account/exports/${exportId}/download`,
);
return GET(request, { params: Promise.resolve({ id: exportId }) });
}

it("returns 401 for unauthenticated request", async () => {
const response = await callDownload("some-id", null);
expect(response.status).toBe(401);
});

it("returns 404 for non-existent export", async () => {
const response = await callDownload(
"00000000-0000-0000-0000-000000000000",
{ userId },
);
expect(response.status).toBe(404);

const body = await response.json();
expect(body.error).toContain("not found");
});

it("returns 404 when accessing another user's export (ownership check)", async () => {
// Create export for userId
const exportResult = await pg.query<{ id: string }>(
"INSERT INTO data_exports (user_id, status) VALUES ($1, 'completed') RETURNING id",
[userId],
);
const exportId = exportResult.rows[0].id;

// Try to access as otherUser
const response = await callDownload(exportId, { userId: otherUserId });
expect(response.status).toBe(404);
});

it("returns 400 when export is not yet completed", async () => {
const exportResult = await pg.query<{ id: string }>(
"INSERT INTO data_exports (user_id, status) VALUES ($1, 'pending') RETURNING id",
[userId],
);
const exportId = exportResult.rows[0].id;

const response = await callDownload(exportId, { userId });
expect(response.status).toBe(400);

const body = await response.json();
expect(body.error).toContain("not yet completed");
});

it("returns 200 with JSON attachment for completed export", async () => {
const exportResult = await pg.query<{ id: string }>(
"INSERT INTO data_exports (user_id, status) VALUES ($1, 'completed') RETURNING id",
[userId],
);
const exportId = exportResult.rows[0].id;

const response = await callDownload(exportId, { userId });
expect(response.status).toBe(200);

expect(response.headers.get("Content-Type")).toBe("application/json");
expect(response.headers.get("Content-Disposition")).toContain(
`data-export-${exportId}.json`,
);

const text = await response.text();
const data = JSON.parse(text);
// Should be a valid export schema with expected keys
expect(data).toHaveProperty("rsvps");
expect(data).toHaveProperty("follows");
expect(data).toHaveProperty("blocks");
expect(data).toHaveProperty("mutes");
});
});
118 changes: 118 additions & 0 deletions apps/web/tests/integration/payments/callback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Integration tests for GET /api/payments/callback — Stripe OAuth callback
*
* Tests redirect behavior for:
* - Error from Stripe → redirect with error description
* - Missing code/state → redirect with missing_params error
* - Successful callback → redirect with success status
* - handleCallback failure → redirect with connection_failed error
*
* Constitution II (Test-First), XII (Financial Integrity)
*/

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { NextRequest } from "next/server";

// Mock handleCallback to avoid real Stripe API calls
vi.mock("@/lib/payments/stripe-connect", () => ({
handleCallback: vi.fn(),
}));

import { handleCallback } from "@/lib/payments/stripe-connect";
const mockHandleCallback = vi.mocked(handleCallback);

describe("GET /api/payments/callback", () => {
beforeEach(() => {
vi.resetModules();
});

afterEach(() => {
vi.resetAllMocks();
});

async function callCallback(searchParams: Record<string, string>) {
const url = new URL("http://localhost/api/payments/callback");
for (const [key, value] of Object.entries(searchParams)) {
url.searchParams.set(key, value);
}
const request = new NextRequest(url);
const { GET } = await import("@/app/api/payments/callback/route");
return GET(request);
}

it("redirects with error description when Stripe returns an error", async () => {
const response = await callCallback({
error: "access_denied",
error_description: "User denied access",
});

expect(response.status).toBe(307);
const location = response.headers.get("Location")!;
expect(location).toContain("/settings/creator");
expect(location).toContain("error=User%20denied%20access");
});

it("uses default error description when none provided", async () => {
const response = await callCallback({
error: "access_denied",
});

expect(response.status).toBe(307);
const location = response.headers.get("Location")!;
expect(location).toContain("error=Unknown%20error");
});

it("redirects with missing_params when code is absent", async () => {
const response = await callCallback({
state: "user-123",
});

expect(response.status).toBe(307);
const location = response.headers.get("Location")!;
expect(location).toContain("error=missing_params");
});

it("redirects with missing_params when state is absent", async () => {
const response = await callCallback({
code: "auth_code_123",
});

expect(response.status).toBe(307);
const location = response.headers.get("Location")!;
expect(location).toContain("error=missing_params");
});

it("redirects with success when handleCallback succeeds", async () => {
mockHandleCallback.mockResolvedValue({
id: "pay_1",
userId: "user-123",
stripeAccountId: "acct_test",
onboardingComplete: false,
connectedAt: new Date().toISOString(),
disconnectedAt: null,
});

const response = await callCallback({
code: "auth_code_123",
state: "user-123",
});

expect(response.status).toBe(307);
const location = response.headers.get("Location")!;
expect(location).toContain("status=success");
expect(mockHandleCallback).toHaveBeenCalledWith("auth_code_123", "user-123");
});

it("redirects with connection_failed when handleCallback throws", async () => {
mockHandleCallback.mockRejectedValue(new Error("Stripe API error"));

const response = await callCallback({
code: "bad_code",
state: "user-123",
});

expect(response.status).toBe(307);
const location = response.headers.get("Location")!;
expect(location).toContain("error=connection_failed");
});
});
Loading
Loading