Refactor API layer into structured domain-specific modules
- Created new api/ directory with domain-specific API modules: - api/client.ts: Base API client with error handling - api/auth.ts: Authentication endpoints - api/exchange.ts: Exchange/price endpoints - api/trades.ts: User trade endpoints - api/profile.ts: Profile management endpoints - api/invites.ts: Invite endpoints - api/admin.ts: Admin endpoints - api/index.ts: Centralized exports - Migrated all API calls from ad-hoc api.get/post/put to typed domain APIs - Updated all imports across codebase - Fixed test mocks to use new API structure - Fixed type issues in validation utilities - Removed old api.ts file Benefits: - Type-safe endpoints (no more string typos) - Centralized API surface (easy to discover endpoints) - Better organization (domain-specific modules) - Uses generated OpenAPI types automatically
This commit is contained in:
parent
6d0f125536
commit
a6fa6a8012
24 changed files with 529 additions and 255 deletions
|
|
@ -53,6 +53,24 @@ vi.mock("../auth-context", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
// Mock profileApi
|
||||
const mockGetProfile = vi.fn();
|
||||
const mockUpdateProfile = vi.fn();
|
||||
|
||||
vi.mock("../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../api")>();
|
||||
// Create a getter that returns the mock functions
|
||||
return {
|
||||
...actual,
|
||||
get profileApi() {
|
||||
return {
|
||||
getProfile: mockGetProfile,
|
||||
updateProfile: mockUpdateProfile,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock profile data
|
||||
const mockProfileData = {
|
||||
contact_email: "contact@example.com",
|
||||
|
|
@ -80,6 +98,9 @@ beforeEach(() => {
|
|||
mockHasPermission.mockImplementation(
|
||||
(permission: string) => mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
// Reset API mocks
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
mockUpdateProfile.mockResolvedValue(mockProfileData);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -89,17 +110,14 @@ afterEach(() => {
|
|||
describe("ProfilePage - Display", () => {
|
||||
test("renders loading state initially", () => {
|
||||
mockIsLoading = true;
|
||||
vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {}));
|
||||
mockGetProfile.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
render(<ProfilePage />);
|
||||
expect(screen.getByText("Loading...")).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders profile page title", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
|
|
@ -108,10 +126,7 @@ describe("ProfilePage - Display", () => {
|
|||
});
|
||||
|
||||
test("displays login email as read-only", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
|
|
@ -122,10 +137,7 @@ describe("ProfilePage - Display", () => {
|
|||
});
|
||||
|
||||
test("shows read-only badge for login email", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
|
|
@ -134,10 +146,7 @@ describe("ProfilePage - Display", () => {
|
|||
});
|
||||
|
||||
test("shows hint about login email", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
|
|
@ -146,10 +155,7 @@ describe("ProfilePage - Display", () => {
|
|||
});
|
||||
|
||||
test("displays contact details section hint", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
|
|
@ -158,10 +164,7 @@ describe("ProfilePage - Display", () => {
|
|||
});
|
||||
|
||||
test("displays fetched profile data", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
|
|
@ -173,34 +176,22 @@ describe("ProfilePage - Display", () => {
|
|||
});
|
||||
|
||||
test("fetches profile with credentials", async () => {
|
||||
const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:8000/api/profile",
|
||||
expect.objectContaining({
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
expect(mockGetProfile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("displays empty fields when profile has null values", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
});
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
|
|
@ -213,10 +204,7 @@ describe("ProfilePage - Display", () => {
|
|||
|
||||
describe("ProfilePage - Navigation", () => {
|
||||
test("shows nav links for regular user", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
|
|
@ -226,10 +214,7 @@ describe("ProfilePage - Navigation", () => {
|
|||
});
|
||||
|
||||
test("highlights My Profile in nav", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
|
|
@ -273,13 +258,12 @@ describe("ProfilePage - Access Control", () => {
|
|||
roles: ["admin"],
|
||||
permissions: ["view_audit"],
|
||||
};
|
||||
const fetchSpy = vi.spyOn(global, "fetch");
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
// Give it a moment to potentially fetch
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mockGetProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -304,10 +288,7 @@ describe("ProfilePage - Loading State", () => {
|
|||
|
||||
describe("ProfilePage - Form Behavior", () => {
|
||||
test("submit button is disabled when no changes", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
|
|
@ -317,10 +298,7 @@ describe("ProfilePage - Form Behavior", () => {
|
|||
});
|
||||
|
||||
test("submit button is enabled after field changes", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
|
@ -338,16 +316,12 @@ describe("ProfilePage - Form Behavior", () => {
|
|||
});
|
||||
|
||||
test("auto-prepends @ to telegram when user starts with letter", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
});
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
|
@ -364,16 +338,12 @@ describe("ProfilePage - Form Behavior", () => {
|
|||
});
|
||||
|
||||
test("does not auto-prepend @ if user types @ first", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
});
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
|
@ -392,28 +362,25 @@ describe("ProfilePage - Form Behavior", () => {
|
|||
|
||||
describe("ProfilePage - Form Submission", () => {
|
||||
test("shows success toast after successful save", async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(global, "fetch")
|
||||
mockGetProfile
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response)
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: "new@example.com",
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
contact_email: "new@example.com",
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
});
|
||||
mockUpdateProfile.mockResolvedValue({
|
||||
contact_email: "new@example.com",
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
});
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
|
@ -433,40 +400,33 @@ describe("ProfilePage - Form Submission", () => {
|
|||
expect(screen.getByText(/saved successfully/i)).toBeDefined();
|
||||
});
|
||||
|
||||
// Verify PUT was called
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:8000/api/profile",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
// Verify updateProfile was called
|
||||
expect(mockUpdateProfile).toHaveBeenCalledWith({
|
||||
contact_email: "new@example.com",
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("shows inline errors from backend validation", async () => {
|
||||
vi.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
detail: {
|
||||
field_errors: {
|
||||
telegram: "Backend error: invalid handle",
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
mockGetProfile.mockResolvedValue({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
});
|
||||
// Import ApiError from the actual module (not mocked)
|
||||
const { ApiError } = await import("../api/client");
|
||||
mockUpdateProfile.mockRejectedValue(
|
||||
new ApiError("Validation failed", 422, {
|
||||
detail: {
|
||||
field_errors: {
|
||||
telegram: "Backend error: invalid handle",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
|
@ -488,18 +448,13 @@ describe("ProfilePage - Form Submission", () => {
|
|||
});
|
||||
|
||||
test("shows error toast on network failure", async () => {
|
||||
vi.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response)
|
||||
.mockRejectedValueOnce(new Error("Network error"));
|
||||
mockGetProfile.mockResolvedValue({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
});
|
||||
mockUpdateProfile.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
|
@ -521,23 +476,28 @@ describe("ProfilePage - Form Submission", () => {
|
|||
});
|
||||
|
||||
test("submit button shows 'Saving...' while submitting", async () => {
|
||||
let resolveSubmit: (value: Response) => void;
|
||||
const submitPromise = new Promise<Response>((resolve) => {
|
||||
let resolveSubmit: (value: {
|
||||
contact_email: string | null;
|
||||
telegram: string | null;
|
||||
signal: string | null;
|
||||
nostr_npub: string | null;
|
||||
}) => void;
|
||||
const submitPromise = new Promise<{
|
||||
contact_email: string | null;
|
||||
telegram: string | null;
|
||||
signal: string | null;
|
||||
nostr_npub: string | null;
|
||||
}>((resolve) => {
|
||||
resolveSubmit = resolve;
|
||||
});
|
||||
|
||||
vi.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response)
|
||||
.mockReturnValueOnce(submitPromise as Promise<Response>);
|
||||
mockGetProfile.mockResolvedValue({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
});
|
||||
mockUpdateProfile.mockReturnValue(submitPromise);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
|
@ -559,15 +519,11 @@ describe("ProfilePage - Form Submission", () => {
|
|||
|
||||
// Resolve the promise
|
||||
resolveSubmit!({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: "new@example.com",
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
contact_email: "new@example.com",
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /save changes/i })).toBeDefined();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
import { api } from "../api";
|
||||
import { profileApi } from "../api";
|
||||
import { extractApiErrorMessage, extractFieldErrors } from "../utils/error-handling";
|
||||
import { Permission } from "../auth-context";
|
||||
import { Header } from "../components/Header";
|
||||
|
|
@ -81,7 +81,7 @@ export default function ProfilePage() {
|
|||
|
||||
const fetchProfile = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<ProfileData>("/api/profile");
|
||||
const data = await profileApi.getProfile();
|
||||
const formValues = toFormData(data);
|
||||
setFormData(formValues);
|
||||
setOriginalData(formValues);
|
||||
|
|
@ -132,7 +132,7 @@ export default function ProfilePage() {
|
|||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const data = await api.put<ProfileData>("/api/profile", {
|
||||
const data = await profileApi.updateProfile({
|
||||
contact_email: formData.contact_email || null,
|
||||
telegram: formData.telegram || null,
|
||||
signal: formData.signal || null,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue