Compare commits

..

8 Commits

9 changed files with 268 additions and 105 deletions

View File

@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import {
deleteNotification,
fetchNotifications,
fetchUnreadCount,
markAllAsRead,
markAsRead,
savePushToken,
} from "../notifications";
import { apiClient, withAuth } from "../client";
jest.mock("../client", () => ({
apiClient: {
get: jest.fn(),
put: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
},
withAuth: jest.fn((token?: string | null) =>
token ? { headers: { Authorization: `Bearer ${token}` } } : {},
),
}));
describe("notifications api", () => {
const getMock = apiClient.get as any;
const putMock = apiClient.put as any;
const postMock = apiClient.post as any;
const deleteMock = apiClient.delete as any;
beforeEach(() => {
jest.clearAllMocks();
});
it("returns notifications and normalizes createdAt", async () => {
getMock.mockResolvedValue({
data: {
success: true,
data: [
{
id: "n_1",
userId: "u_1",
title: "Hi",
message: "Welcome",
type: "system",
read: false,
createdAt: "2026-03-29T10:00:00.000Z",
},
],
},
});
const result = await fetchNotifications("token_1");
expect(result).toHaveLength(1);
expect(result[0].createdAt).toBeInstanceOf(Date);
expect(withAuth).toHaveBeenCalledWith("token_1");
});
it("returns unread count from response wrapper", async () => {
getMock.mockResolvedValue({
data: {
success: true,
data: { count: 3 },
},
});
const count = await fetchUnreadCount(null);
expect(count).toBe(3);
});
it("marks notification as read", async () => {
putMock.mockResolvedValue({
data: {
success: true,
data: {
id: "n_1",
userId: "u_1",
title: "Hi",
message: "Welcome",
type: "system",
read: true,
createdAt: "2026-03-29T10:00:00.000Z",
},
},
});
const result = await markAsRead("n_1", "token_1");
expect(result.read).toBe(true);
expect(putMock).toHaveBeenCalledWith(
"/api/notifications/n_1",
{},
expect.any(Object),
);
});
it("calls mark-all, delete and save-token endpoints", async () => {
postMock.mockResolvedValue({});
deleteMock.mockResolvedValue({});
await markAllAsRead("token_1");
await deleteNotification("n_2", "token_1");
await savePushToken("expo-token", "android", "token_1");
expect(postMock).toHaveBeenCalledWith(
"/api/notifications/mark-all-read",
{},
expect.any(Object),
);
expect(deleteMock).toHaveBeenCalledWith(
"/api/notifications/n_2",
expect.any(Object),
);
expect(postMock).toHaveBeenCalledWith(
"/api/notifications/save-token",
{ expoPushToken: "expo-token", deviceType: "android" },
expect.any(Object),
);
});
});

View File

@ -38,9 +38,12 @@ describe("recommendations api", () => {
expect(withAuth).toHaveBeenCalledWith("token_1");
});
it("falls back to legacy response for generate", async () => {
it("returns recommendation from standardized response for generate", async () => {
postMock.mockResolvedValue({
data: { id: "rec_2", status: "pending" },
data: {
success: true,
data: { id: "rec_2", status: "pending" },
},
});
const result = await generateRecommendation({ userId: "user_1" }, null);

View File

@ -0,0 +1,14 @@
import { ApiError, handleResponse } from "./responses";
import { type ApiResponse } from "./types";
export function parseApiData<T>(payload: unknown): T {
if (Array.isArray(payload)) {
return payload as T;
}
if (payload && typeof payload === "object" && "success" in payload) {
return handleResponse(payload as ApiResponse<T>);
}
throw new ApiError("Invalid response format");
}

View File

@ -13,4 +13,5 @@ export * from "./recommendations";
export * from "./nutrition";
export * from "./hydration";
export * from "./client";
export * from "./helpers";
export * from "./gyms";

View File

@ -1,5 +1,6 @@
import { isAxiosError } from "axios";
import { apiClient, withAuth } from "./client";
import { parseApiData } from "./helpers";
export interface Notification {
id: string;
@ -11,15 +12,6 @@ export interface Notification {
createdAt: Date;
}
interface ApiResponse<T> {
success: boolean;
data: T;
meta?: {
timestamp: string;
count?: number;
};
}
/**
* Fetch all notifications for the authenticated user
*/
@ -27,21 +19,12 @@ export async function fetchNotifications(
token: string | null,
): Promise<Notification[]> {
try {
const response = await apiClient.get<ApiResponse<Notification[]>>(
"/api/notifications",
withAuth(token),
);
const result = response.data;
if (result.success && result.data) {
return result.data.map((notification) => ({
...notification,
createdAt: new Date(notification.createdAt),
}));
}
return [];
const response = await apiClient.get("/api/notifications", withAuth(token));
const notifications = parseApiData<Notification[]>(response.data);
return notifications.map((notification) => ({
...notification,
createdAt: new Date(notification.createdAt),
}));
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
@ -57,12 +40,12 @@ export async function fetchNotifications(
*/
export async function fetchUnreadCount(token: string | null): Promise<number> {
try {
const response = await apiClient.get<ApiResponse<{ count: number }>>(
const response = await apiClient.get(
"/api/notifications/unread-count",
withAuth(token),
);
const result = response.data;
return result.success && result.data ? result.data.count : 0;
const data = parseApiData<{ count: number }>(response.data);
return data.count;
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(`Failed to fetch unread count: ${error.response.status}`);
@ -79,29 +62,22 @@ export async function markAsRead(
token: string | null,
): Promise<Notification> {
try {
const response = await apiClient.put<ApiResponse<Notification>>(
const response = await apiClient.put(
`/api/notifications/${notificationId}`,
{},
withAuth(token),
);
const result = response.data;
if (result.success && result.data) {
return {
...result.data,
createdAt: new Date(result.data.createdAt),
};
}
throw new Error("Invalid response format");
const notification = parseApiData<Notification>(response.data);
return {
...notification,
createdAt: new Date(notification.createdAt),
};
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to mark notification as read: ${error.response.status}`,
);
}
if (error instanceof Error && error.message === "Invalid response format") {
throw error;
}
throw new Error("Failed to mark notification as read");
}
}

View File

@ -1,6 +1,7 @@
import { isAxiosError } from "axios";
import { apiClient, withAuth } from "./client";
import { API_ENDPOINTS } from "../config/api";
import { parseApiData } from "./helpers";
export interface Recommendation {
id: string;
@ -23,15 +24,6 @@ export interface GenerateRecommendationRequest {
modelProvider?: "openai" | "deepseek" | "ollama";
}
interface ApiResponse<T> {
success?: boolean;
data?: T;
}
function isApiResponse<T>(value: unknown): value is ApiResponse<T> {
return typeof value === "object" && value !== null && "success" in value;
}
/**
* Get recommendations for a user
*
@ -43,34 +35,20 @@ export async function getRecommendations(
userId: string,
token: string | null,
): Promise<Recommendation[]> {
let result: ApiResponse<Recommendation[]> | Recommendation[];
try {
const response = await apiClient.get(API_ENDPOINTS.RECOMMENDATIONS, {
params: { userId },
...withAuth(token),
});
result = response.data;
return parseApiData<Recommendation[]>(response.data);
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to fetch recommendations: ${error.response.status}`,
);
}
throw new Error("Failed to fetch recommendations");
throw error;
}
// Handle standardized API response format
// API returns: { success: true, data: [...], meta: {...} }
if (
isApiResponse<Recommendation[]>(result) &&
result.success &&
result.data
) {
return Array.isArray(result.data) ? result.data : [];
}
// Fallback for legacy format (direct array)
return Array.isArray(result) ? result : [];
}
/**
@ -84,30 +62,21 @@ export async function generateRecommendation(
data: GenerateRecommendationRequest,
token: string | null,
): Promise<Recommendation> {
let result: ApiResponse<Recommendation> | Recommendation;
try {
const response = await apiClient.post(
`${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
data,
withAuth(token),
);
result = response.data;
return parseApiData<Recommendation>(response.data);
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to generate recommendation: ${error.response.status}`,
);
}
throw new Error("Failed to generate recommendation");
throw error;
}
// Handle standardized API response format
if (isApiResponse<Recommendation>(result) && result.success && result.data) {
return result.data;
}
// Fallback for legacy format
return result as Recommendation;
}
/**
@ -115,14 +84,12 @@ export async function generateRecommendation(
*
* @param recommendationId - Recommendation ID
* @param token - Auth token
* @param approvedBy - User ID of the approver (optional)
* @returns The approved recommendation
*/
export async function approveRecommendation(
recommendationId: string,
token: string | null,
): Promise<Recommendation> {
let result: ApiResponse<Recommendation> | Recommendation;
try {
const response = await apiClient.post(
`${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
@ -132,21 +99,13 @@ export async function approveRecommendation(
},
withAuth(token),
);
result = response.data;
return parseApiData<Recommendation>(response.data);
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to approve recommendation: ${error.response.status}`,
);
}
throw new Error("Failed to approve recommendation");
throw error;
}
// Handle standardized API response format
if (isApiResponse<Recommendation>(result) && result.success && result.data) {
return result.data;
}
// Fallback for legacy format
return result as Recommendation;
}

View File

@ -4,12 +4,14 @@ import * as SecureStore from "expo-secure-store";
import { View, Text } from "react-native";
import { useEffect, useState } from "react";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { QueryClientProvider } from "@tanstack/react-query";
import { validateEnv } from "../utils/env";
import { ThemeProvider } from "../contexts/ThemeContext";
import { StatisticsProvider } from "../contexts/StatisticsContext";
import { FitnessGoalsProvider } from "../contexts/FitnessGoalsContext";
import { RecommendationsProvider } from "../contexts/RecommendationsContext";
import { NotificationsProvider } from "../contexts/NotificationsContext";
import { queryClient } from "../lib/query-client";
import log from "../utils/logger";
// Wrapper to use notification permissions hook after ClerkLoaded
@ -172,21 +174,23 @@ export default function RootLayout() {
return (
<SafeAreaProvider>
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
<ClerkLoaded>
<ThemeProvider>
<NotificationsProvider>
<StatisticsProvider>
<FitnessGoalsProvider>
<RecommendationsProvider>
<AppContent />
</RecommendationsProvider>
</FitnessGoalsProvider>
</StatisticsProvider>
</NotificationsProvider>
</ThemeProvider>
</ClerkLoaded>
</ClerkProvider>
<QueryClientProvider client={queryClient}>
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
<ClerkLoaded>
<ThemeProvider>
<NotificationsProvider>
<StatisticsProvider>
<FitnessGoalsProvider>
<RecommendationsProvider>
<AppContent />
</RecommendationsProvider>
</FitnessGoalsProvider>
</StatisticsProvider>
</NotificationsProvider>
</ThemeProvider>
</ClerkLoaded>
</ClerkProvider>
</QueryClientProvider>
</SafeAreaProvider>
);
}

View File

@ -0,0 +1,18 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30000,
gcTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
},
},
});
export const userScopedKey = (
resource: string,
userId: string,
): [string, string] => [resource, userId];

View File

@ -0,0 +1,68 @@
# Release Hardening Checklist
This checklist is the final validation gate before shipping security and mobile API changes.
## 1) Pre-Release Validation
- [ ] Confirm target branch is up to date with `master`
- [ ] Verify no unintended files are staged (`git status --short`)
- [ ] Confirm release notes summarize risky changes (authz, API contracts, caching)
## 2) Automated Checks
Run from repository root:
```bash
npm run typecheck:admin
npm run typecheck:mobile
npm run test:admin
```
Run mobile API-focused tests:
```bash
cd apps/mobile
npm run test -- src/api/__tests__/gyms.test.ts src/api/__tests__/recommendations.test.ts src/api/__tests__/notifications.test.ts
```
## 3) Admin Security Spot Checks
- [ ] Verify non-admin receives `403` on privileged routes
- [ ] Verify unauthenticated requests receive `401`
- [ ] Verify cross-gym actions are denied for non-superAdmin
- [ ] Verify `DELETE /api/users` blocks self-delete for admin users
- [ ] Verify recommendation approval derives approver from auth context (not request body)
## 4) Mobile Functional Smoke Checks
- [ ] Sign in as User A and load tabs/profile data
- [ ] Sign out and sign in as User B
- [ ] Confirm no User A data remains in goals, hydration, nutrition, stats, recommendations, notifications
- [ ] Confirm onboarding gym selection and profile save flow still succeed
- [ ] Confirm notifications load and unread count updates after read/delete actions
## 5) Rollback Plan
If release incidents occur:
1. Revert the release commit(s) from newest to oldest.
2. Redeploy reverted build.
3. Validate authentication and onboarding flows.
4. Post incident note with root cause and follow-up action.
Suggested rollback command pattern:
```bash
git revert <latest_commit_sha>
git revert <previous_commit_sha>
```
## 6) Deployment Record
Capture this in PR/release notes:
- Release date/time:
- Release owner:
- Commits included:
- Validation commands run:
- Known caveats (if any):