fix(12-07): make ['me'] fresh on shell entry so post-wizard banner clears (gap 6)
- Root cause confirmed = mechanism (ii): ['me'] staleness, NOT a backend linking gap (upsertUser claim preserves users.id → credential stays linked → DB needsProviderSetup=false) - SetupBanner ['me'] query staleTime 5min → 0 so a pre-claim stale cache entry is refetched on mount; banner hides once needsProviderSetup resolves false - App.tsx boot ['me'] staleTime also set to 0 (committed with Task 1) for the same reason - Add SetupBanner.test.tsx regression: absent when false, present (no dismiss) when true, stale-cache refetch hides banner; success-only dismissal contract preserved (no X button) - Log pre-existing PWA lint errors (SetupPage.test.tsx, setupClient.contract.test.ts) to deferred-items.md
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* SetupBanner.test.tsx — regression tests for the gap-6 ['me'] freshness fix
|
||||
* (Phase 12 Plan 07 Task 2) plus the success-only dismissal contract.
|
||||
*
|
||||
* gap 6 mechanism (ii) — ['me'] staleness, NOT a backend linking gap:
|
||||
* The first-login claim in upsertUser (apps/api/src/auth/user.ts) preserves the
|
||||
* same users.id, so the wizard-stored CalDAV credential stays linked and the DB
|
||||
* correctly reports needsProviderSetup=false after the operator authenticates.
|
||||
* The bug was purely client-cache: a ['me'] entry populated BEFORE the claim
|
||||
* (e.g. a pre-auth visit) served a stale needsProviderSetup=true for up to the
|
||||
* 5-minute staleTime, so the "Set up your calendar" banner kept showing.
|
||||
* Fix: the banner's ['me'] query uses staleTime 0, so a stale cache entry is
|
||||
* refetched on mount and the banner reflects the just-claimed credential.
|
||||
*
|
||||
* Tests cover:
|
||||
* - needsProviderSetup=false → banner absent
|
||||
* - needsProviderSetup=true → banner present (no dismiss/X button)
|
||||
* - staleTime 0: a pre-seeded stale ['me'] (needsProviderSetup=true) is refetched
|
||||
* on mount; once fetchMe resolves needsProviderSetup=false, the banner hides
|
||||
* (mirrors the post-wizard claim where the DB now reports false)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
// CredentialSheet is rendered (closed) by SetupBanner — mock it to avoid the
|
||||
// real sheet's DOM/focus-trap complexity. We're testing banner visibility only.
|
||||
vi.mock('./CredentialSheet.js', () => ({
|
||||
CredentialSheet: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../api/client.js', () => ({
|
||||
fetchMe: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchMe } from '../api/client.js';
|
||||
import type { Mock } from 'vitest';
|
||||
import { SetupBanner } from './SetupBanner.js';
|
||||
|
||||
const mockFetchMe = fetchMe as Mock;
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderBanner(queryClient: QueryClient) {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SetupBanner />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function meUser(needsProviderSetup: boolean) {
|
||||
return {
|
||||
user: {
|
||||
id: 1,
|
||||
displayName: 'Test User',
|
||||
color: '#4a90d9',
|
||||
isAdmin: false,
|
||||
needsProviderSetup,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('SetupBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not render when needsProviderSetup is false', async () => {
|
||||
mockFetchMe.mockResolvedValue(meUser(false));
|
||||
|
||||
const queryClient = makeQueryClient();
|
||||
renderBanner(queryClient);
|
||||
|
||||
// Give the query a tick to resolve, then confirm the banner stays absent
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(screen.queryByText('Set up your calendar')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the banner (no dismiss button) when needsProviderSetup is true', async () => {
|
||||
mockFetchMe.mockResolvedValue(meUser(true));
|
||||
|
||||
const queryClient = makeQueryClient();
|
||||
renderBanner(queryClient);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up your calendar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The ONLY action is "Set up now" — there is NO dismiss/X button (T-05-24).
|
||||
expect(screen.getByRole('button', { name: 'Set up now' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /dismiss/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /close/i })).toBeNull();
|
||||
});
|
||||
|
||||
// gap 6 regression: a stale ['me'] cache entry (needsProviderSetup=true, as it
|
||||
// would be from a pre-claim visit) must be refetched on mount because the query
|
||||
// uses staleTime 0. After the fresh fetch resolves needsProviderSetup=false (the
|
||||
// post-wizard-claim DB truth), the banner must hide.
|
||||
it('refetches a stale ["me"] on mount (staleTime 0) and hides the banner once needsProviderSetup is false', async () => {
|
||||
const queryClient = makeQueryClient();
|
||||
|
||||
// Seed a STALE cache entry as if populated before the wizard claim.
|
||||
queryClient.setQueryData(['me'], meUser(true));
|
||||
|
||||
// The server now reports the claimed credential → needsProviderSetup=false.
|
||||
mockFetchMe.mockResolvedValue(meUser(false));
|
||||
|
||||
renderBanner(queryClient);
|
||||
|
||||
// staleTime 0 → the seeded entry is stale → refetch fires on mount.
|
||||
await waitFor(() => {
|
||||
expect(mockFetchMe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// After the refetch resolves false, the banner must disappear.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Set up your calendar')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -33,11 +33,18 @@ export function SetupBanner() {
|
||||
// Use HTMLButtonElement for the ref (assignable to the CredentialSheet's HTMLElement trigger)
|
||||
const ctaRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// staleTime 0 (gap 6): this banner gates on needsProviderSetup, which is only
|
||||
// correct if ['me'] is fresh on entry to the authenticated shell after the
|
||||
// wizard claim (see App.tsx for the full mechanism note). With a 5-minute
|
||||
// staleTime a pre-claim cache entry kept needsProviderSetup=true and the banner
|
||||
// showed even though the operator already configured the calendar in the wizard.
|
||||
// The success-only dismissal contract is unchanged: this banner still clears
|
||||
// ONLY when needsProviderSetup becomes false (no dismiss/X button).
|
||||
const meQuery = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: fetchMe,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Only show when needsProviderSetup is explicitly true
|
||||
|
||||
Reference in New Issue
Block a user