Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,15 @@ jobs:
- name: Run shared-ui tests
run: npm run test -w @acroyoga/shared-ui

- name: Run shared tests
run: npm run test -w @acroyoga/shared

- name: Run web tests
run: npm run test -w @acroyoga/web

- name: Run mobile unit tests (Spec 016)
run: npm run test -w @acroyoga/mobile

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

Expand Down
28 changes: 28 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,34 @@ apps/web/messages/
└── ar.json # Arabic (RTL stub for structural testing)
```

## Mobile Development

The mobile app is built with Expo/React Native and lives in `apps/mobile/`.

### Prerequisites
- Node.js >= 22
- Expo CLI: `npm install -g expo-cli`
- iOS Simulator (macOS) or Android Emulator

### Getting Started
```bash
cd apps/mobile
npx expo start
```

### Running Tests
```bash
cd apps/mobile
npm test
```

### Building
Preview builds use EAS Build:
```bash
npx eas build --profile preview --platform ios
npx eas build --profile preview --platform android
```

## Getting Help

- Read the [constitution](specs/constitution.md) for architectural principles
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ This is an **npm workspaces monorepo** with shared packages:
| `packages/shared` | `@acroyoga/shared` | Shared types and contracts |
| `packages/shared-ui` | `@acroyoga/shared-ui` | 17 cross-platform UI components with design tokens |
| `packages/tokens` | `@acroyoga/tokens` | Design token pipeline (CSS, TS, Swift, Kotlin output) |
| `apps/mobile` | `@acroyoga/mobile` | Expo/React Native mobile app |

### Specifications

Expand All @@ -95,7 +96,7 @@ Each feature is developed from a full spec (user scenarios, data model, API cont
| 013 | [Platform Improvements](specs/013-platform-improvements/) | P2 | Complete |
| 014 | [Internationalisation](specs/014-internationalisation/) | P1 | Implemented |
| 015 | [Background Jobs & Notifications](specs/015-background-jobs-notifications/) | P1 | Implemented |
| 016 | [Mobile App (Expo/React Native)](specs/016-mobile-app/) | P1 | Planned |
| 016 | [Mobile App (Expo/React Native)](specs/016-mobile-app/) | P1 | Implemented |

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

Expand Down
181 changes: 181 additions & 0 deletions apps/mobile/__tests__/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Unit tests for mobile API client
* Spec: 016-mobile-app (T007)
*/
import { get, post, put, del, configureApiClient } from "../lib/api-client";
import * as auth from "../lib/auth";

// Mock the auth module
jest.mock("../lib/auth");

const mockAuth = auth as jest.Mocked<typeof auth>;

// Mock global fetch
const mockFetch = jest.fn();
global.fetch = mockFetch;

describe("api-client", () => {
beforeEach(() => {
jest.clearAllMocks();
configureApiClient({ baseUrl: "http://test-api.com" });
mockAuth.getToken.mockResolvedValue("test-token");
mockAuth.signOut.mockResolvedValue(undefined);
});

describe("get", () => {
it("sends GET request with auth header", async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ events: [] }),
});

const result = await get<{ events: unknown[] }>("/api/events");
expect(result.data).toEqual({ events: [] });
expect(result.error).toBeNull();
expect(result.status).toBe(200);

expect(mockFetch).toHaveBeenCalledWith(
"http://test-api.com/api/events",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer test-token",
}),
}),
);
});

it("appends query params", async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ events: [] }),
});

await get("/api/events", { category: "workshop" });
expect(mockFetch).toHaveBeenCalledWith(
"http://test-api.com/api/events?category=workshop",
expect.any(Object),
);
});

it("returns error for non-ok responses", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ error: "Not found" }),
});

const result = await get("/api/events/999");
expect(result.data).toBeNull();
expect(result.error).toBe("Not found");
expect(result.status).toBe(404);
});
});

describe("post", () => {
it("sends POST request with JSON body", async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ id: "rsvp-1" }),
});

const result = await post("/api/rsvps", { eventId: "e1", role: "base" });
expect(result.data).toEqual({ id: "rsvp-1" });

expect(mockFetch).toHaveBeenCalledWith(
"http://test-api.com/api/rsvps",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ eventId: "e1", role: "base" }),
}),
);
});
});

describe("put", () => {
it("sends PUT request", async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ updated: true }),
});

const result = await put("/api/profile", { name: "Updated" });
expect(result.data).toEqual({ updated: true });
});
});

describe("del", () => {
it("sends DELETE request", async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ deleted: true }),
});

const result = await del("/api/rsvps/123");
expect(result.data).toEqual({ deleted: true });
});
});

describe("401 handling with token refresh", () => {
it("retries request after successful token refresh", async () => {
const newTokens = {
token: "new-token",
refreshToken: "new-refresh",
expiresAt: new Date(Date.now() + 86400000).toISOString(),
};
mockAuth.refreshToken.mockResolvedValue(newTokens);

// First call: 401, second call: 200
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ error: "Unauthorized" }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: "success" }),
});

const result = await get<{ data: string }>("/api/protected");
expect(result.data).toEqual({ data: "success" });
expect(mockAuth.refreshToken).toHaveBeenCalledWith(
"http://test-api.com",
);
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it("signs out when refresh fails", async () => {
mockAuth.refreshToken.mockResolvedValue(null);

mockFetch.mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ error: "Unauthorized" }),
});

const result = await get("/api/protected");
expect(result.status).toBe(401);
expect(mockAuth.signOut).toHaveBeenCalled();
});

it("does not retry when no token exists", async () => {
mockAuth.getToken.mockResolvedValue(null);

mockFetch.mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ error: "Unauthorized" }),
});

await get("/api/protected");
expect(mockAuth.refreshToken).not.toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
});
143 changes: 143 additions & 0 deletions apps/mobile/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Unit tests for mobile auth module
* Spec: 016-mobile-app (T006)
*/
import * as SecureStore from "expo-secure-store";
import {
storeTokens,
getToken,
getRefreshToken,
isTokenExpired,
isAuthenticated,
signOut,
} from "../lib/auth";

describe("auth module", () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe("storeTokens", () => {
it("stores token, refreshToken, and expiresAt in SecureStore", async () => {
const tokens = {
token: "test-jwt",
refreshToken: "test-refresh",
expiresAt: "2026-12-31T00:00:00.000Z",
};
await storeTokens(tokens);

expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
"auth_token",
"test-jwt",
);
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
"auth_refresh_token",
"test-refresh",
);
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
"auth_token_expiry",
"2026-12-31T00:00:00.000Z",
);
});
});

describe("getToken", () => {
it("retrieves token from SecureStore", async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue("stored-jwt");
const token = await getToken();
expect(token).toBe("stored-jwt");
expect(SecureStore.getItemAsync).toHaveBeenCalledWith("auth_token");
});

it("returns null when no token stored", async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null);
const token = await getToken();
expect(token).toBeNull();
});
});

describe("getRefreshToken", () => {
it("retrieves refresh token from SecureStore", async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(
"stored-refresh",
);
const token = await getRefreshToken();
expect(token).toBe("stored-refresh");
expect(SecureStore.getItemAsync).toHaveBeenCalledWith(
"auth_refresh_token",
);
});
});

describe("isTokenExpired", () => {
it("returns true when no expiry is stored", async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null);
expect(await isTokenExpired()).toBe(true);
});

it("returns false when token is not yet expired", async () => {
const future = new Date();
future.setHours(future.getHours() + 2);
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(
future.toISOString(),
);
expect(await isTokenExpired()).toBe(false);
});

it("returns true when token is expired", async () => {
const past = new Date();
past.setHours(past.getHours() - 1);
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(
past.toISOString(),
);
expect(await isTokenExpired()).toBe(true);
});

it("considers token expired 60 seconds before actual expiry", async () => {
const almostExpired = new Date();
almostExpired.setSeconds(almostExpired.getSeconds() + 30); // 30s left (within 60s buffer)
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(
almostExpired.toISOString(),
);
expect(await isTokenExpired()).toBe(true);
});
});

describe("isAuthenticated", () => {
it("returns false when no token exists", async () => {
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null);
expect(await isAuthenticated()).toBe(false);
});

it("returns true when token exists and is not expired", async () => {
const future = new Date();
future.setHours(future.getHours() + 2);
(SecureStore.getItemAsync as jest.Mock)
.mockResolvedValueOnce("valid-jwt") // getToken -> TOKEN_KEY
.mockResolvedValueOnce(future.toISOString()); // isTokenExpired -> TOKEN_EXPIRY_KEY
expect(await isAuthenticated()).toBe(true);
});

it("returns false when token exists but is expired", async () => {
const past = new Date();
past.setHours(past.getHours() - 1);
(SecureStore.getItemAsync as jest.Mock)
.mockResolvedValueOnce("expired-jwt") // getToken
.mockResolvedValueOnce(past.toISOString()); // isTokenExpired
expect(await isAuthenticated()).toBe(false);
});
});

describe("signOut", () => {
it("removes all tokens from SecureStore", async () => {
await signOut();
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("auth_token");
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
"auth_refresh_token",
);
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
"auth_token_expiry",
);
});
});
});
Loading
Loading