Compare commits
No commits in common. "0ddac10c59348a4e9a628e1ef7af884802a5b469" and "b1f84722af38f71a2e2288d9ff0b77589d461afc" have entirely different histories.
0ddac10c59
...
b1f84722af
@ -1,120 +0,0 @@
|
|||||||
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),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -38,12 +38,9 @@ describe("recommendations api", () => {
|
|||||||
expect(withAuth).toHaveBeenCalledWith("token_1");
|
expect(withAuth).toHaveBeenCalledWith("token_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns recommendation from standardized response for generate", async () => {
|
it("falls back to legacy response for generate", async () => {
|
||||||
postMock.mockResolvedValue({
|
postMock.mockResolvedValue({
|
||||||
data: {
|
|
||||||
success: true,
|
|
||||||
data: { id: "rec_2", status: "pending" },
|
data: { id: "rec_2", status: "pending" },
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await generateRecommendation({ userId: "user_1" }, null);
|
const result = await generateRecommendation({ userId: "user_1" }, null);
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
@ -13,5 +13,4 @@ export * from "./recommendations";
|
|||||||
export * from "./nutrition";
|
export * from "./nutrition";
|
||||||
export * from "./hydration";
|
export * from "./hydration";
|
||||||
export * from "./client";
|
export * from "./client";
|
||||||
export * from "./helpers";
|
|
||||||
export * from "./gyms";
|
export * from "./gyms";
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
import { apiClient, withAuth } from "./client";
|
import { apiClient, withAuth } from "./client";
|
||||||
import { parseApiData } from "./helpers";
|
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
@ -12,6 +11,15 @@ export interface Notification {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
meta?: {
|
||||||
|
timestamp: string;
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all notifications for the authenticated user
|
* Fetch all notifications for the authenticated user
|
||||||
*/
|
*/
|
||||||
@ -19,12 +27,21 @@ export async function fetchNotifications(
|
|||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Notification[]> {
|
): Promise<Notification[]> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get("/api/notifications", withAuth(token));
|
const response = await apiClient.get<ApiResponse<Notification[]>>(
|
||||||
const notifications = parseApiData<Notification[]>(response.data);
|
"/api/notifications",
|
||||||
return notifications.map((notification) => ({
|
withAuth(token),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = response.data;
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
return result.data.map((notification) => ({
|
||||||
...notification,
|
...notification,
|
||||||
createdAt: new Date(notification.createdAt),
|
createdAt: new Date(notification.createdAt),
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAxiosError(error) && error.response) {
|
if (isAxiosError(error) && error.response) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -40,12 +57,12 @@ export async function fetchNotifications(
|
|||||||
*/
|
*/
|
||||||
export async function fetchUnreadCount(token: string | null): Promise<number> {
|
export async function fetchUnreadCount(token: string | null): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get<ApiResponse<{ count: number }>>(
|
||||||
"/api/notifications/unread-count",
|
"/api/notifications/unread-count",
|
||||||
withAuth(token),
|
withAuth(token),
|
||||||
);
|
);
|
||||||
const data = parseApiData<{ count: number }>(response.data);
|
const result = response.data;
|
||||||
return data.count;
|
return result.success && result.data ? result.data.count : 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAxiosError(error) && error.response) {
|
if (isAxiosError(error) && error.response) {
|
||||||
throw new Error(`Failed to fetch unread count: ${error.response.status}`);
|
throw new Error(`Failed to fetch unread count: ${error.response.status}`);
|
||||||
@ -62,22 +79,29 @@ export async function markAsRead(
|
|||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Notification> {
|
): Promise<Notification> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(
|
const response = await apiClient.put<ApiResponse<Notification>>(
|
||||||
`/api/notifications/${notificationId}`,
|
`/api/notifications/${notificationId}`,
|
||||||
{},
|
{},
|
||||||
withAuth(token),
|
withAuth(token),
|
||||||
);
|
);
|
||||||
const notification = parseApiData<Notification>(response.data);
|
|
||||||
|
const result = response.data;
|
||||||
|
if (result.success && result.data) {
|
||||||
return {
|
return {
|
||||||
...notification,
|
...result.data,
|
||||||
createdAt: new Date(notification.createdAt),
|
createdAt: new Date(result.data.createdAt),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
throw new Error("Invalid response format");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAxiosError(error) && error.response) {
|
if (isAxiosError(error) && error.response) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to mark notification as read: ${error.response.status}`,
|
`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");
|
throw new Error("Failed to mark notification as read");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
import { apiClient, withAuth } from "./client";
|
import { apiClient, withAuth } from "./client";
|
||||||
import { API_ENDPOINTS } from "../config/api";
|
import { API_ENDPOINTS } from "../config/api";
|
||||||
import { parseApiData } from "./helpers";
|
|
||||||
|
|
||||||
export interface Recommendation {
|
export interface Recommendation {
|
||||||
id: string;
|
id: string;
|
||||||
@ -24,6 +23,15 @@ export interface GenerateRecommendationRequest {
|
|||||||
modelProvider?: "openai" | "deepseek" | "ollama";
|
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
|
* Get recommendations for a user
|
||||||
*
|
*
|
||||||
@ -35,20 +43,34 @@ export async function getRecommendations(
|
|||||||
userId: string,
|
userId: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Recommendation[]> {
|
): Promise<Recommendation[]> {
|
||||||
|
let result: ApiResponse<Recommendation[]> | Recommendation[];
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(API_ENDPOINTS.RECOMMENDATIONS, {
|
const response = await apiClient.get(API_ENDPOINTS.RECOMMENDATIONS, {
|
||||||
params: { userId },
|
params: { userId },
|
||||||
...withAuth(token),
|
...withAuth(token),
|
||||||
});
|
});
|
||||||
return parseApiData<Recommendation[]>(response.data);
|
result = response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAxiosError(error) && error.response) {
|
if (isAxiosError(error) && error.response) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to fetch recommendations: ${error.response.status}`,
|
`Failed to fetch recommendations: ${error.response.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw new Error("Failed to fetch recommendations");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -62,21 +84,30 @@ export async function generateRecommendation(
|
|||||||
data: GenerateRecommendationRequest,
|
data: GenerateRecommendationRequest,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Recommendation> {
|
): Promise<Recommendation> {
|
||||||
|
let result: ApiResponse<Recommendation> | Recommendation;
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
|
`${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
|
||||||
data,
|
data,
|
||||||
withAuth(token),
|
withAuth(token),
|
||||||
);
|
);
|
||||||
return parseApiData<Recommendation>(response.data);
|
result = response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAxiosError(error) && error.response) {
|
if (isAxiosError(error) && error.response) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to generate recommendation: ${error.response.status}`,
|
`Failed to generate recommendation: ${error.response.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw new Error("Failed to generate recommendation");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle standardized API response format
|
||||||
|
if (isApiResponse<Recommendation>(result) && result.success && result.data) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for legacy format
|
||||||
|
return result as Recommendation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -84,12 +115,14 @@ export async function generateRecommendation(
|
|||||||
*
|
*
|
||||||
* @param recommendationId - Recommendation ID
|
* @param recommendationId - Recommendation ID
|
||||||
* @param token - Auth token
|
* @param token - Auth token
|
||||||
|
* @param approvedBy - User ID of the approver (optional)
|
||||||
* @returns The approved recommendation
|
* @returns The approved recommendation
|
||||||
*/
|
*/
|
||||||
export async function approveRecommendation(
|
export async function approveRecommendation(
|
||||||
recommendationId: string,
|
recommendationId: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<Recommendation> {
|
): Promise<Recommendation> {
|
||||||
|
let result: ApiResponse<Recommendation> | Recommendation;
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
|
`${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
|
||||||
@ -99,13 +132,21 @@ export async function approveRecommendation(
|
|||||||
},
|
},
|
||||||
withAuth(token),
|
withAuth(token),
|
||||||
);
|
);
|
||||||
return parseApiData<Recommendation>(response.data);
|
result = response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAxiosError(error) && error.response) {
|
if (isAxiosError(error) && error.response) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to approve recommendation: ${error.response.status}`,
|
`Failed to approve recommendation: ${error.response.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw new Error("Failed to approve recommendation");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle standardized API response format
|
||||||
|
if (isApiResponse<Recommendation>(result) && result.success && result.data) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for legacy format
|
||||||
|
return result as Recommendation;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,14 +4,12 @@ import * as SecureStore from "expo-secure-store";
|
|||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { validateEnv } from "../utils/env";
|
import { validateEnv } from "../utils/env";
|
||||||
import { ThemeProvider } from "../contexts/ThemeContext";
|
import { ThemeProvider } from "../contexts/ThemeContext";
|
||||||
import { StatisticsProvider } from "../contexts/StatisticsContext";
|
import { StatisticsProvider } from "../contexts/StatisticsContext";
|
||||||
import { FitnessGoalsProvider } from "../contexts/FitnessGoalsContext";
|
import { FitnessGoalsProvider } from "../contexts/FitnessGoalsContext";
|
||||||
import { RecommendationsProvider } from "../contexts/RecommendationsContext";
|
import { RecommendationsProvider } from "../contexts/RecommendationsContext";
|
||||||
import { NotificationsProvider } from "../contexts/NotificationsContext";
|
import { NotificationsProvider } from "../contexts/NotificationsContext";
|
||||||
import { queryClient } from "../lib/query-client";
|
|
||||||
import log from "../utils/logger";
|
import log from "../utils/logger";
|
||||||
|
|
||||||
// Wrapper to use notification permissions hook after ClerkLoaded
|
// Wrapper to use notification permissions hook after ClerkLoaded
|
||||||
@ -174,7 +172,6 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
|
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
|
||||||
<ClerkLoaded>
|
<ClerkLoaded>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
@ -190,7 +187,6 @@ export default function RootLayout() {
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ClerkLoaded>
|
</ClerkLoaded>
|
||||||
</ClerkProvider>
|
</ClerkProvider>
|
||||||
</QueryClientProvider>
|
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
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];
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
# 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):
|
|
||||||
Loading…
Reference in New Issue
Block a user