import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import AccountPage from '../app/account/page'; import { AppCloudData } from '../components/app-cloud-data'; import { firebaseAuth } from '../lib/firebase-client'; const authListeners = new Set<(user: unknown) => void>(); const mocks = vi.hoisted(() => ({ getDocs: vi.fn(), deleteDoc: vi.fn(), updateDoc: vi.fn(), addDoc: vi.fn(), writeBatch: vi.fn(() => ({ update: vi.fn(), commit: vi.fn().mockResolvedValue(undefined), })), })); vi.mock('next/navigation', () => ({ usePathname: () => '/account', useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }), })); vi.mock('../lib/firebase-client', () => ({ firebaseAuth: {}, firestore: {}, })); vi.mock('firebase/auth', () => ({ onAuthStateChanged: (_auth: unknown, deliver: (user: unknown) => void) => { authListeners.add(deliver); return () => { authListeners.delete(deliver); }; }, multiFactor: () => ({ enrolledFactors: [] }), })); vi.mock('firebase/firestore', () => ({ collection: (...args: unknown[]) => ({ path: args.slice(1).join('/') }), doc: (...args: unknown[]) => ({ path: args.slice(1).join('/'), id: args[args.length - 1] }), getDocs: (...args: unknown[]) => mocks.getDocs(...args), deleteDoc: (...args: unknown[]) => mocks.deleteDoc(...args), updateDoc: (...args: unknown[]) => mocks.updateDoc(...args), addDoc: (...args: unknown[]) => mocks.addDoc(...args), writeBatch: () => mocks.writeBatch(), })); function deliverAuth(user: unknown) { (firebaseAuth as { currentUser?: unknown }).currentUser = user; authListeners.forEach((listener) => listener(user)); } describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => { afterEach(() => { cleanup(); authListeners.clear(); (firebaseAuth as { currentUser?: unknown }).currentUser = undefined; vi.clearAllMocks(); }); const authUser = { uid: 'rider-123', email: 'trackday@example.com', displayName: 'Test Rider', }; it('distinguishes initial loading: shows Loading… and Checking sync…, never 0 miles, 0 rides, or 100% Cloud Synced', async () => { // Keep getDocs pending so it stays in loading state let resolveMotorcycles: (val: unknown) => void; let resolveRides: (val: unknown) => void; mocks.getDocs.mockImplementation((colRef: { path: string }) => { if (colRef.path.includes('motorcycles')) { return new Promise((res) => { resolveMotorcycles = res; }); } return new Promise((res) => { resolveRides = res; }); }); render(); deliverAuth(authUser); // Metric 1: Distance must show Loading…, not 0 miles const distanceCard = screen.getByTestId('metric-card-distance'); expect(distanceCard.textContent).toContain('Loading…'); expect(distanceCard.textContent).not.toContain('0 miles'); // Metric 2: Sessions must show Checking sync… or Loading…, not 0 rides or 100% Cloud Synced const sessionsCard = screen.getByTestId('metric-card-sessions'); expect(sessionsCard.textContent).toContain('Checking sync…'); expect(sessionsCard.textContent).not.toContain('0 rides'); expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); // Metric 3: Lean Angle Peak must show Loading…, not 0 or false verified roll const leanCard = screen.getByTestId('metric-card-lean'); expect(leanCard.textContent).toContain('Loading…'); expect(leanCard.textContent).toContain('Retrieving lean telemetry…'); }); it('truthfully handles signed-out state: shows sign-in required, never 0 miles or 100% Cloud Synced', async () => { render(); deliverAuth(null); await waitFor(() => { const distanceCard = screen.getByTestId('metric-card-distance'); expect(distanceCard.textContent).toContain('Sign in to view'); expect(distanceCard.textContent).toContain('—'); expect(distanceCard.textContent).not.toContain('0 miles'); }); const sessionsCard = screen.getByTestId('metric-card-sessions'); expect(sessionsCard.textContent).toContain('Sign-in required'); expect(sessionsCard.textContent).toContain('—'); expect(sessionsCard.textContent).not.toContain('0 rides'); expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); const leanCard = screen.getByTestId('metric-card-lean'); expect(leanCard.textContent).toContain('Sign in to view'); expect(leanCard.textContent).toContain('—'); }); it('distinguishes real zero-ride account: shows verified 0 miles, 0 rides, Cloud Synced (NOT 100% Cloud Synced)', async () => { mocks.getDocs.mockImplementation((colRef: { path: string }) => { return Promise.resolve({ docs: [], empty: true }); }); render(); deliverAuth(authUser); await waitFor(() => { const distanceCard = screen.getByTestId('metric-card-distance'); expect(distanceCard.textContent).toContain('0'); expect(distanceCard.textContent).toContain('miles'); expect(distanceCard.textContent).toContain('Cloud Aggregate'); }); const sessionsCard = screen.getByTestId('metric-card-sessions'); expect(sessionsCard.textContent).toContain('0'); expect(sessionsCard.textContent).toContain('rides'); // Must NOT claim "100% Cloud Synced" on an empty account with no rides expect(sessionsCard.textContent).toContain('Cloud Synced'); expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); expect(sessionsCard.textContent).toContain('No rides recorded yet'); }); it('displays real calculated totals for populated accounts and claims 100% Cloud Synced only when rides exist', async () => { const mockRides = [ { id: 'ride-1', data: () => ({ startTime: '2026-07-01T10:00:00Z', endTime: '2026-07-01T11:00:00Z', distanceMiles: 45.2, topSpeedMph: 72.4, averageSpeed: 45.2, maxLeanLeft: 42.1, maxLeanRight: 45.6, maxAccelG: 0.65, maxBrakingG: 0.82, movingTime: 3600, elevationGain: 850, qualityScore: 98, motorcycleYear: 2024, motorcycleMake: 'Yamaha', motorcycleModel: 'YZF-R7', }), }, { id: 'ride-2', data: () => ({ startTime: '2026-07-02T14:00:00Z', endTime: '2026-07-02T15:30:00Z', distanceMiles: 54.8, topSpeedMph: 85.0, averageSpeed: 52.0, maxLeanLeft: 48.5, maxLeanRight: 46.0, maxAccelG: 0.75, maxBrakingG: 0.90, movingTime: 5400, elevationGain: 1200, qualityScore: 95, motorcycleYear: 2024, motorcycleMake: 'Yamaha', motorcycleModel: 'YZF-R7', }), }, ]; mocks.getDocs.mockImplementation((colRef: { path: string }) => { if (colRef.path.includes('rides')) { return Promise.resolve({ docs: mockRides, empty: false }); } return Promise.resolve({ docs: [], empty: true }); }); render(); deliverAuth(authUser); await waitFor(() => { const distanceCard = screen.getByTestId('metric-card-distance'); // 45.2 + 54.8 = 100 miles expect(distanceCard.textContent).toContain('100'); expect(distanceCard.textContent).toContain('miles'); }); const sessionsCard = screen.getByTestId('metric-card-sessions'); expect(sessionsCard.textContent).toContain('2'); expect(sessionsCard.textContent).toContain('rides'); // With real rides loaded, it is confirmed 100% Cloud Synced expect(sessionsCard.textContent).toContain('100% Cloud Synced'); expect(sessionsCard.textContent).toContain('Canyons, Circuits & Commutes'); const leanCard = screen.getByTestId('metric-card-lean'); expect(leanCard.textContent).toContain('48.5°'); expect(leanCard.textContent).toContain('6-Axis IMU Verified'); }); it('truthfully handles Firestore permission denied: shows Unavailable and Permission denied, never verified zero totals', async () => { mocks.getDocs.mockImplementation(() => { const err = new Error('Missing or insufficient permissions.'); (err as unknown as { code: string }).code = 'permission-denied'; return Promise.reject(err); }); render(); deliverAuth(authUser); await waitFor(() => { const distanceCard = screen.getByTestId('metric-card-distance'); expect(distanceCard.textContent).toContain('Unavailable'); expect(distanceCard.textContent).toContain('Permission denied'); expect(distanceCard.textContent).not.toContain('0 miles'); }); const sessionsCard = screen.getByTestId('metric-card-sessions'); expect(sessionsCard.textContent).toContain('Unavailable'); expect(sessionsCard.textContent).toContain('Permission denied'); expect(sessionsCard.textContent).not.toContain('0 rides'); expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); const leanCard = screen.getByTestId('metric-card-lean'); expect(leanCard.textContent).toContain('Unavailable'); expect(leanCard.textContent).toContain('Permission denied'); expect(leanCard.textContent).toContain('Lean telemetry restricted'); expect(leanCard.textContent).not.toContain('No IMU roll data'); // AppCloudData error banners expect(screen.getByTestId('cloud-garage-error')).toBeDefined(); expect(screen.getByTestId('cloud-rides-error')).toBeDefined(); expect(screen.getByText(/Recent Rides Restricted — Permission Denied/i)).toBeDefined(); }); it('truthfully handles network / temporary backend failure: shows Unavailable and Sync status unavailable', async () => { mocks.getDocs.mockImplementation(() => { const err = new Error('Failed to fetch: client is offline'); (err as unknown as { code: string }).code = 'unavailable'; return Promise.reject(err); }); render(); deliverAuth(authUser); await waitFor(() => { const distanceCard = screen.getByTestId('metric-card-distance'); expect(distanceCard.textContent).toContain('Unavailable'); expect(distanceCard.textContent).toContain('Sync unavailable'); expect(distanceCard.textContent).not.toContain('0 miles'); }); const sessionsCard = screen.getByTestId('metric-card-sessions'); expect(sessionsCard.textContent).toContain('Unavailable'); expect(sessionsCard.textContent).toContain('Sync status unavailable'); expect(sessionsCard.textContent).not.toContain('0 rides'); expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); const leanCard = screen.getByTestId('metric-card-lean'); expect(leanCard.textContent).toContain('Unavailable'); expect(leanCard.textContent).toContain('Sync unavailable'); expect(leanCard.textContent).toContain('Lean telemetry unavailable'); expect(leanCard.textContent).not.toContain('No IMU roll data'); expect(screen.getByTestId('cloud-rides-error')).toBeDefined(); expect(screen.getByText(/Recent Rides Cloud Feed Unavailable/i)).toBeDefined(); }); it('handles partial data availability: shows garage when motorcycles load, but keeps rides metrics Unavailable when rides query fails', async () => { const mockBike = { id: 'bike-1', data: () => ({ year: 2024, makeName: 'Ducati', modelName: 'Panigale V4', variantName: 'SP2', nickname: 'Red Bullet', isDefault: true, }), }; mocks.getDocs.mockImplementation((colRef: { path: string }) => { if (colRef.path.includes('motorcycles')) { return Promise.resolve({ docs: [mockBike], empty: false }); } const err = new Error('Rides query unavailable'); (err as unknown as { code: string }).code = 'unavailable'; return Promise.reject(err); }); render(); deliverAuth(authUser); await waitFor(() => { // Bike rendered in garage expect(screen.getByText('Red Bullet')).toBeDefined(); }); // But ride metrics MUST show Unavailable, not zero! const sessionsCard = screen.getByTestId('metric-card-sessions'); expect(sessionsCard.textContent).toContain('Unavailable'); expect(sessionsCard.textContent).not.toContain('0 rides'); expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); // Rides feed in AppCloudData shows error expect(screen.getByTestId('cloud-rides-error')).toBeDefined(); }); it('recovers after retry: Quick Sync displays error on failure and updates to Synced on successful retry', async () => { let shouldFail = true; mocks.getDocs.mockImplementation((colRef: { path: string }) => { if (shouldFail) { const err = new Error('Network error'); (err as unknown as { code: string }).code = 'unavailable'; return Promise.reject(err); } if (colRef.path.includes('rides')) { return Promise.resolve({ docs: [ { id: 'ride-1', data: () => ({ startTime: '2026-07-01T10:00:00Z', endTime: '2026-07-01T11:00:00Z', distanceMiles: 15.0, topSpeedMph: 60, averageSpeed: 35, maxLeanLeft: 30, maxLeanRight: 32, maxAccelG: 0.5, maxBrakingG: 0.6, movingTime: 1200, elevationGain: 200, qualityScore: 90, motorcycleYear: 2024, motorcycleMake: 'Yamaha', motorcycleModel: 'MT-07', }), }, ], empty: false, }); } return Promise.resolve({ docs: [], empty: true }); }); render(); deliverAuth(authUser); // Initial failure await waitFor(() => { expect(screen.getByText(/Sync Failed \(Retry\)/i)).toBeDefined(); }); // Make next fetch succeed shouldFail = false; // Click retry via Quick Sync button const quickSyncBtn = screen.getByRole('button', { name: /Sync Failed \(Retry\)/i }); fireEvent.click(quickSyncBtn); // Recovered state await waitFor(() => { const sessionsCard = screen.getByTestId('metric-card-sessions'); expect(sessionsCard.textContent).toContain('1'); expect(sessionsCard.textContent).toContain('rides'); expect(sessionsCard.textContent).toContain('100% Cloud Synced'); }); expect(screen.getByText(/Synced \(/i)).toBeDefined(); }); it('guards against race conditions when user logs out or switches accounts during in-flight fetch', async () => { let resolveMotorcycles: (val: unknown) => void; let resolveRides: (val: unknown) => void; mocks.getDocs.mockImplementation((colRef: { path: string }) => { if (colRef.path.includes('motorcycles')) { return new Promise((res) => { resolveMotorcycles = res; }); } return new Promise((res) => { resolveRides = res; }); }); render(); deliverAuth(authUser); // Initial loading expect(screen.getByTestId('metric-card-distance').textContent).toContain('Loading…'); // User logs out while fetch is still in-flight deliverAuth(null); // Now the in-flight promise resolves with User A's data resolveMotorcycles!({ docs: [{ id: 'bike-user-a', data: () => ({ year: 2024, makeName: 'Yamaha', modelName: 'R7', nickname: 'Ghost Bike' }), }], empty: false, }); resolveRides!({ docs: [{ id: 'ride-user-a', data: () => ({ distanceMiles: 50, maxLeanLeft: 35, maxLeanRight: 35, startTime: '2026-07-01T10:00:00Z' }), }], empty: false, }); // Wait to confirm User A's ghost data does NOT leak into logged-out state await waitFor(() => { const distanceCard = screen.getByTestId('metric-card-distance'); expect(distanceCard.textContent).toContain('Sign in to view'); expect(distanceCard.textContent).not.toContain('50'); expect(screen.queryByText('Ghost Bike')).toBeNull(); }); }); it('clears metrics state when an error occurs so stale metrics from a prior successful sync are not retained', async () => { let shouldFail = false; mocks.getDocs.mockImplementation((colRef: { path: string }) => { if (shouldFail) { const err = new Error('Permission denied on re-sync'); (err as unknown as { code: string }).code = 'permission-denied'; return Promise.reject(err); } if (colRef.path.includes('rides')) { return Promise.resolve({ docs: [{ id: 'ride-1', data: () => ({ distanceMiles: 25.0, maxLeanLeft: 30, maxLeanRight: 32, startTime: '2026-07-01T10:00:00Z' }), }], empty: false, }); } return Promise.resolve({ docs: [], empty: true }); }); render(); deliverAuth(authUser); // Verify initial successful sync await waitFor(() => { expect(screen.getByTestId('metric-card-distance').textContent).toContain('25'); }); // Now trigger Quick Sync and make it fail shouldFail = true; const quickSyncBtn = screen.getByRole('button', { name: /Synced/i }); fireEvent.click(quickSyncBtn); // Verify that metrics are immediately cleared and show Unavailable, not the stale 25 miles await waitFor(() => { const distanceCard = screen.getByTestId('metric-card-distance'); expect(distanceCard.textContent).toContain('Unavailable'); expect(distanceCard.textContent).not.toContain('25'); const sessionsCard = screen.getByTestId('metric-card-sessions'); expect(sessionsCard.textContent).toContain('Unavailable'); expect(sessionsCard.textContent).not.toContain('1 rides'); const leanCard = screen.getByTestId('metric-card-lean'); expect(leanCard.textContent).toContain('Unavailable'); }); }); });