Add logout functionality and Garmin sync structured logging

- Add POST /api/auth/logout endpoint with tests (5 tests)
- Add logout button to settings page (5 tests)
- Add structured logging to garmin-sync cron (sync start/complete/failure)
- Update IMPLEMENTATION_PLAN.md with spec gap analysis findings
- Total: 835 tests passing across 44 test files

Closes spec gaps from authentication.md (logout) and observability.md (logging)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 23:00:54 +00:00
parent e9a77fd79c
commit 13b58c3c32
7 changed files with 411 additions and 7 deletions

View File

@@ -296,7 +296,7 @@ describe("SettingsPage", () => {
expect(cycleLengthInput).toBeDisabled();
expect(screen.getByLabelText(/notification time/i)).toBeDisabled();
expect(screen.getByLabelText(/timezone/i)).toBeDisabled();
expect(screen.getByRole("button")).toBeDisabled();
expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled();
});
resolveSave(mockUser);
@@ -525,4 +525,150 @@ describe("SettingsPage", () => {
});
});
});
describe("logout", () => {
it("renders a logout button", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /log out/i }),
).toBeInTheDocument();
});
});
it("calls POST /api/auth/logout when logout button clicked", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
success: true,
message: "Logged out successfully",
redirectTo: "/login",
}),
});
render(<SettingsPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /log out/i }),
).toBeInTheDocument();
});
const logoutButton = screen.getByRole("button", { name: /log out/i });
fireEvent.click(logoutButton);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/auth/logout", {
method: "POST",
});
});
});
it("redirects to login page after logout", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
success: true,
message: "Logged out successfully",
redirectTo: "/login",
}),
});
render(<SettingsPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /log out/i }),
).toBeInTheDocument();
});
const logoutButton = screen.getByRole("button", { name: /log out/i });
fireEvent.click(logoutButton);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/login");
});
});
it("shows loading state while logging out", async () => {
let resolveLogout: (value: unknown) => void = () => {};
const logoutPromise = new Promise((resolve) => {
resolveLogout = resolve;
});
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
})
.mockReturnValueOnce({
ok: true,
json: () => logoutPromise,
});
render(<SettingsPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /log out/i }),
).toBeInTheDocument();
});
const logoutButton = screen.getByRole("button", { name: /log out/i });
fireEvent.click(logoutButton);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /logging out/i }),
).toBeInTheDocument();
});
resolveLogout({
success: true,
message: "Logged out successfully",
redirectTo: "/login",
});
});
it("shows error if logout fails", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
})
.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Logout failed" }),
});
render(<SettingsPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /log out/i }),
).toBeInTheDocument();
});
const logoutButton = screen.getByRole("button", { name: /log out/i });
fireEvent.click(logoutButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/logout failed/i)).toBeInTheDocument();
});
});
});
});