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:
counterweight 2025-12-25 20:32:11 +01:00
parent 6d0f125536
commit a6fa6a8012
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
24 changed files with 529 additions and 255 deletions

View file

@ -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();