From c7cb8f1d1b05232d7cb9387c53939ad2ed4cc0c7 Mon Sep 17 00:00:00 2001 From: hzijad Date: Fri, 29 May 2026 17:11:33 +0200 Subject: [PATCH 1/8] feat(mobile): cover backend integration flows --- apps/mobile/jest.config.js | 22 +++ apps/mobile/src/App.spec.tsx | 33 ++++- apps/mobile/src/app/analytics.spec.tsx | 100 +++++++++++++ apps/mobile/src/app/analytics.tsx | 79 +++++++++-- apps/mobile/src/app/documentation.spec.tsx | 133 ++++++++++++++++++ apps/mobile/src/app/documentation.tsx | 63 ++++++++- apps/mobile/src/app/home.spec.tsx | 98 +++++++++++++ apps/mobile/src/app/home.tsx | 61 +++++++- .../services/analytics-api.service.spec.ts | 73 ++++++++++ .../src/services/analytics-api.service.ts | 97 +++++++++++++ .../src/services/library-api.service.spec.ts | 94 +++++++++++++ .../src/services/library-api.service.ts | 107 ++++++++++++++ apps/mobile/src/test-setup.ts | 33 +++++ 13 files changed, 970 insertions(+), 23 deletions(-) create mode 100644 apps/mobile/jest.config.js create mode 100644 apps/mobile/src/app/analytics.spec.tsx create mode 100644 apps/mobile/src/app/documentation.spec.tsx create mode 100644 apps/mobile/src/app/home.spec.tsx create mode 100644 apps/mobile/src/services/analytics-api.service.spec.ts create mode 100644 apps/mobile/src/services/analytics-api.service.ts create mode 100644 apps/mobile/src/services/library-api.service.spec.ts create mode 100644 apps/mobile/src/services/library-api.service.ts diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js new file mode 100644 index 0000000..a7956cc --- /dev/null +++ b/apps/mobile/jest.config.js @@ -0,0 +1,22 @@ +/// +/// +module.exports = { + displayName: 'mobile', + preset: 'jest-expo', + moduleFileExtensions: ['ts', 'js', 'html', 'tsx', 'jsx'], + setupFilesAfterEnv: ['/src/test-setup.ts'], + moduleNameMapper: { + '[.]svg$': '@nx/expo/plugins/jest/svg-mock', + }, + transform: { + '[.][jt]sx?$': [ + 'babel-jest', + { + configFile: __dirname + '/.babelrc.js', + }, + ], + '^.+[.](bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp|ttf|otf|m4v|mov|mp4|mpeg|mpg|webm|aac|aiff|caf|m4a|mp3|wav|html|pdf|obj)$': + require.resolve('jest-expo/src/preset/assetFileTransformer.js'), + }, + coverageDirectory: '../../coverage/apps/mobile', +}; diff --git a/apps/mobile/src/App.spec.tsx b/apps/mobile/src/App.spec.tsx index 24e8419..4a5efaa 100644 --- a/apps/mobile/src/App.spec.tsx +++ b/apps/mobile/src/App.spec.tsx @@ -1,9 +1,36 @@ import * as React from 'react'; import { render } from '@testing-library/react-native'; -import App from './App'; +import App from './app'; +import { useAuth } from './services/auth.service'; + +jest.mock('./services/auth.service', () => ({ + useAuth: jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { count?: number }) => { + const translations: Record = { + 'home.title': 'Home', + 'home.jobs_visible': `${options?.count ?? 0} jobs visible on map`, + 'map.you': 'You', + 'map.current_location': 'Current location', + 'unit.mi': 'mi', + 'status.active': 'Active', + 'status.upcoming': 'Upcoming', + }; + return translations[key] ?? key; + }, + }), +})); test('renders correctly', () => { - const { getByTestId } = render(); - expect(getByTestId('heading')).toHaveTextContent(/Welcome/); + (useAuth as jest.Mock).mockReturnValue({ token: null }); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + const { getByText } = render(); + expect(getByText('Home')).toBeTruthy(); + + jest.restoreAllMocks(); }); diff --git a/apps/mobile/src/app/analytics.spec.tsx b/apps/mobile/src/app/analytics.spec.tsx new file mode 100644 index 0000000..ecbf394 --- /dev/null +++ b/apps/mobile/src/app/analytics.spec.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react-native'; + +import AnalyticsScreen from './analytics'; +import { useAuth } from '../services/auth.service'; +import { + fetchAnalyticsEarnings, + fetchAnalyticsSummary, +} from '../services/analytics-api.service'; + +jest.mock('../services/auth.service', () => ({ + useAuth: jest.fn(), +})); + +jest.mock('../services/analytics-api.service', () => ({ + fetchAnalyticsSummary: jest.fn(), + fetchAnalyticsEarnings: jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { count?: string | number }) => { + const translations: Record = { + 'analytics.title': 'Analytics', + 'analytics.header_copy': + 'Track the numbers behind jobs, fault patterns, and service demand.', + 'analytics.export': 'Export', + 'analytics.year_to_date': 'Year-to-date volume', + 'analytics.jobs_count': `${options?.count ?? 0} jobs`, + }; + return translations[key] ?? key; + }, + }), +})); + +const useAuthMock = useAuth as jest.Mock; +const fetchSummaryMock = fetchAnalyticsSummary as jest.Mock; +const fetchEarningsMock = fetchAnalyticsEarnings as jest.Mock; + +describe('AnalyticsScreen backend integration', () => { + beforeEach(() => { + useAuthMock.mockReturnValue({ token: 'token-123' }); + fetchSummaryMock.mockResolvedValue({ + jobsTotal: 87, + completionRate: 98.2, + faultRepeatRate: 5.25, + avgResponseTime: 27, + }); + fetchEarningsMock.mockResolvedValue([ + { month: 'Mar', jobs: 4, revenue: 10 }, + { month: 'Apr', jobs: 8, revenue: 20 }, + ]); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('fetches analytics summary and earnings with the auth token', async () => { + render(); + + await waitFor(() => { + expect(fetchSummaryMock).toHaveBeenCalledWith('token-123'); + expect(fetchEarningsMock).toHaveBeenCalledWith('token-123'); + }); + }); + + it('renders API metrics and monthly earning data', async () => { + const screen = render(); + + expect(await screen.findByText('87')).toBeTruthy(); + expect(screen.getByText('98%')).toBeTruthy(); + expect(screen.getByText('5.3%')).toBeTruthy(); + expect(screen.getByText('27m')).toBeTruthy(); + expect(screen.getByText('12 jobs')).toBeTruthy(); + expect(screen.getAllByText('Apr').length).toBeGreaterThan(0); + }); + + it('keeps fallback metrics when analytics calls fail', async () => { + fetchSummaryMock.mockRejectedValueOnce(new Error('network down')); + + const screen = render(); + + expect(await screen.findByText('72%')).toBeTruthy(); + expect(screen.getByText('8.4%')).toBeTruthy(); + expect(screen.getByText('34m')).toBeTruthy(); + }); + + it('does not call analytics endpoints without an auth token', () => { + useAuthMock.mockReturnValue({ token: null }); + + render(); + + expect(fetchSummaryMock).not.toHaveBeenCalled(); + expect(fetchEarningsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/app/analytics.tsx b/apps/mobile/src/app/analytics.tsx index a4f5991..ef177ed 100644 --- a/apps/mobile/src/app/analytics.tsx +++ b/apps/mobile/src/app/analytics.tsx @@ -1,5 +1,5 @@ import Ionicons from '@expo/vector-icons/Ionicons'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Pressable, SafeAreaView, @@ -9,6 +9,13 @@ import { View, } from 'react-native'; import { useTranslation } from 'react-i18next'; +import { useAuth } from '../services/auth.service'; +import { + fetchAnalyticsSummary, + fetchAnalyticsEarnings, + AnalyticsSummary, + MonthlyMetric, +} from '../services/analytics-api.service'; type MetricCard = { label: string; @@ -79,20 +86,72 @@ function toneStyles(tone: MetricCard['tone']) { export default function AnalyticsScreen() { const { t } = useTranslation(); + const { token } = useAuth(); + const [summary, setSummary] = useState({}); + const [monthlyData, setMonthlyData] = useState(MONTHLY_DATA); + + useEffect(() => { + if (!token) return; + + (async () => { + try { + // Fetch analytics data from backend + const summaryData = await fetchAnalyticsSummary(token); + setSummary(summaryData); + + const earningsData = await fetchAnalyticsEarnings(token); + if (earningsData && earningsData.length > 0) { + setMonthlyData(earningsData); + } + } catch (error) { + console.error('Failed to fetch analytics:', error); + // Keep mock data on error + } + })(); + }, [token]); + const jobsTotal = useMemo( - () => MONTHLY_DATA.reduce((sum, point) => sum + point.jobs, 0), - [] + () => monthlyData.reduce((sum, point) => sum + point.jobs, 0) || summary.jobsTotal || 0, + [monthlyData, summary] ); const revenueTotal = useMemo( - () => MONTHLY_DATA.reduce((sum, point) => sum + point.revenue, 0), - [] + () => monthlyData.reduce((sum, point) => sum + point.revenue, 0) || summary.revenueTotal || 0, + [monthlyData, summary] ); const peakMonth = useMemo( - () => [...MONTHLY_DATA].sort((a, b) => b.jobs - a.jobs)[0], - [] + () => [...monthlyData].sort((a, b) => b.jobs - a.jobs)[0] || { month: 'N/A', jobs: 0, revenue: 0 }, + [monthlyData] ); - const maxJobs = Math.max(...MONTHLY_DATA.map((point) => point.jobs)); + const maxJobs = Math.max(...monthlyData.map((point) => point.jobs), 1); + + // Create metrics from API data + const metrics = useMemo(() => [ + { + label: 'Jobs this year', + value: summary.jobsTotal ? summary.jobsTotal.toLocaleString() : '0', + delta: '+18%', + tone: 'blue' as const, + }, + { + label: 'Completed on first visit', + value: summary.completionRate ? `${Math.round(summary.completionRate)}%` : '72%', + delta: '+6%', + tone: 'emerald' as const, + }, + { + label: 'Fault repeat rate', + value: summary.faultRepeatRate ? `${summary.faultRepeatRate.toFixed(1)}%` : '8.4%', + delta: '-2%', + tone: 'amber' as const, + }, + { + label: 'Avg. response time', + value: summary.avgResponseTime ? `${summary.avgResponseTime}m` : '34m', + delta: '-8m', + tone: 'slate' as const, + }, + ], [summary]); return ( @@ -167,7 +226,7 @@ export default function AnalyticsScreen() { - {METRICS.map((metric) => { + {metrics.map((metric) => { const colors = toneStyles(metric.tone); return ( @@ -196,7 +255,7 @@ export default function AnalyticsScreen() { - {MONTHLY_DATA.map((point) => { + {monthlyData.map((point) => { const heightPercent = (point.jobs / maxJobs) * 100; return ( diff --git a/apps/mobile/src/app/documentation.spec.tsx b/apps/mobile/src/app/documentation.spec.tsx new file mode 100644 index 0000000..b3174a6 --- /dev/null +++ b/apps/mobile/src/app/documentation.spec.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; + +import DocumentationScreen from './documentation'; +import { useAuth } from '../services/auth.service'; +import { searchLibrary } from '../services/library-api.service'; + +jest.mock('../services/auth.service', () => ({ + useAuth: jest.fn(), +})); + +jest.mock('../services/library-api.service', () => ({ + searchLibrary: jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'documentation.title': 'Documentation', + 'documentation.header_copy': + 'Search fault codes, boiler types, brands, and quick fixes.', + 'documentation.search_placeholder': + 'Search fault code, keyword, boiler or brand', + 'documentation.search': 'Search', + 'documentation.popular_references': 'Popular References', + 'documentation.search_results': 'Search Results', + 'documentation.top_topics': 'Top topics', + 'documentation.hero_title': + 'Fast access to the most used field references.', + }; + return translations[key] ?? key; + }, + }), +})); + +const useAuthMock = useAuth as jest.Mock; +const searchLibraryMock = searchLibrary as jest.Mock; + +describe('DocumentationScreen backend integration', () => { + beforeEach(() => { + jest.useFakeTimers(); + useAuthMock.mockReturnValue({ token: 'token-123' }); + searchLibraryMock.mockResolvedValue([ + { + id: 'fault-f22', + title: 'API Fault Code F22', + category: 'Vaillant', + type: 'fault', + description: 'Low pressure from API', + }, + ]); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('filters local documentation before API search is requested', () => { + const screen = render(); + + fireEvent.changeText( + screen.getByPlaceholderText('Search fault code, keyword, boiler or brand'), + 'F28' + ); + + expect(screen.getByText('Fault Code F28')).toBeTruthy(); + expect(screen.queryByText('Fault Code F22')).toBeNull(); + expect(searchLibraryMock).not.toHaveBeenCalled(); + }); + + it('runs debounced API search from the Search button and renders API results', async () => { + const screen = render(); + + fireEvent.changeText( + screen.getByPlaceholderText('Search fault code, keyword, boiler or brand'), + 'F22' + ); + fireEvent.press(screen.getByText('Search')); + + act(() => { + jest.advanceTimersByTime(299); + }); + expect(searchLibraryMock).not.toHaveBeenCalled(); + + await act(async () => { + jest.advanceTimersByTime(1); + }); + + await waitFor(() => { + expect(searchLibraryMock).toHaveBeenCalledWith('token-123', 'F22'); + }); + expect(await screen.findByText('API Fault Code F22')).toBeTruthy(); + expect(screen.getByText('Low pressure from API')).toBeTruthy(); + }); + + it('falls back to local results when API search fails', async () => { + searchLibraryMock.mockRejectedValueOnce(new Error('network down')); + const screen = render(); + + fireEvent.changeText( + screen.getByPlaceholderText('Search fault code, keyword, boiler or brand'), + 'F22' + ); + fireEvent.press(screen.getByText('Search')); + + await act(async () => { + jest.advanceTimersByTime(300); + }); + + expect(await screen.findByText('Fault Code F22')).toBeTruthy(); + }); + + it('does not call API search without an auth token', () => { + useAuthMock.mockReturnValue({ token: null }); + const screen = render(); + + fireEvent.changeText( + screen.getByPlaceholderText('Search fault code, keyword, boiler or brand'), + 'F22' + ); + fireEvent.press(screen.getByText('Search')); + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(searchLibraryMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/app/documentation.tsx b/apps/mobile/src/app/documentation.tsx index 67f0d7a..1ccedf8 100644 --- a/apps/mobile/src/app/documentation.tsx +++ b/apps/mobile/src/app/documentation.tsx @@ -1,5 +1,5 @@ import Ionicons from '@expo/vector-icons/Ionicons'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Pressable, SafeAreaView, @@ -10,6 +10,8 @@ import { View, } from 'react-native'; import { useTranslation } from 'react-i18next'; +import { useAuth } from '../services/auth.service'; +import { searchLibrary, LibrarySearchResult } from '../services/library-api.service'; type DocumentationItem = { id: string; @@ -138,8 +140,32 @@ function accentColors(accent: DocumentationItem['accent']) { export default function DocumentationScreen() { const { t } = useTranslation(); + const { token } = useAuth(); const [query, setQuery] = useState(''); + const [apiResults, setApiResults] = useState([]); + const [useApiSearch, setUseApiSearch] = useState(false); + // Handle API search + useEffect(() => { + if (!token || !query.trim() || !useApiSearch) { + setApiResults([]); + return; + } + + const debounceTimer = setTimeout(async () => { + try { + const results = await searchLibrary(token, query); + setApiResults(results); + } catch (error) { + console.error('API search error:', error); + setApiResults([]); + } + }, 300); + + return () => clearTimeout(debounceTimer); + }, [query, token, useApiSearch]); + + // Local search fallback const filteredItems = useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); @@ -160,9 +186,11 @@ export default function DocumentationScreen() { return searchableText.includes(normalizedQuery); }); - }, [query]); + }, [query, useApiSearch]); const hasQuery = query.trim().length > 0; + const displayItems = useApiSearch && apiResults.length > 0 ? apiResults : filteredItems; + const itemCount = displayItems.length; return ( @@ -189,7 +217,10 @@ export default function DocumentationScreen() { /> - + setUseApiSearch(query.trim().length > 0)} + > {t('documentation.search')} @@ -217,7 +248,7 @@ export default function DocumentationScreen() { {hasQuery ? t('documentation.search_results') : t('documentation.popular_references')} - {filteredItems.length} items + {itemCount} items @@ -241,7 +272,7 @@ export default function DocumentationScreen() { - {filteredItems.length === 0 ? ( + {itemCount === 0 ? ( No matching documentation found @@ -249,7 +280,29 @@ export default function DocumentationScreen() { Try a fault code like F22, a brand like Vaillant, or a keyword like pressure. + ) : useApiSearch && apiResults.length > 0 ? ( + // API search results + apiResults.map((item) => ( + + + + + + {item.type} + + + {item.title} + + + + {item.category} + {item.description && ( + {item.description} + )} + + )) ) : ( + // Local search results filteredItems.map((item) => { const colors = accentColors(item.accent); diff --git a/apps/mobile/src/app/home.spec.tsx b/apps/mobile/src/app/home.spec.tsx new file mode 100644 index 0000000..0df133b --- /dev/null +++ b/apps/mobile/src/app/home.spec.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react-native'; + +import HomeScreen from './home'; +import { useAuth } from '../services/auth.service'; + +jest.mock('../services/auth.service', () => ({ + useAuth: jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { count?: number }) => { + const translations: Record = { + 'home.title': 'Home', + 'home.jobs_visible': `${options?.count ?? 0} jobs visible on map`, + 'map.you': 'You', + 'map.current_location': 'Current location', + 'unit.mi': 'mi', + 'status.active': 'Active', + 'status.upcoming': 'Upcoming', + }; + return translations[key] ?? key; + }, + }), +})); + +const fetchMock = global.fetch as jest.Mock; +const useAuthMock = useAuth as jest.Mock; + +describe('HomeScreen backend integration', () => { + beforeEach(() => { + fetchMock.mockReset(); + useAuthMock.mockReturnValue({ token: 'token-123' }); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(Math, 'random').mockReturnValue(0.5); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('fetches jobs from the shared API URL and renders live jobs on the map summary', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + id: 'job-1', + siteId: 'site-1', + rawAddress: 'Live Customer Site', + status: 'IN_PROGRESS', + }, + { + id: 'job-2', + siteId: null, + rawAddress: 'Missing Site', + status: 'SCHEDULED', + }, + ], + }); + + const screen = render(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/jobs', + { + method: 'GET', + headers: { + Authorization: 'Bearer token-123', + 'Content-Type': 'application/json', + }, + } + ); + }); + + expect(await screen.findByText('Live Customer Site')).toBeTruthy(); + expect(screen.getByText('1 jobs visible on map')).toBeTruthy(); + }); + + it('keeps mock jobs visible when the jobs API fails', async () => { + fetchMock.mockRejectedValueOnce(new Error('network down')); + + const screen = render(); + + expect(await screen.findByText('Brooklyn Public Library')).toBeTruthy(); + expect(screen.getByText('3 jobs visible on map')).toBeTruthy(); + }); + + it('does not call the jobs endpoint without an auth token', () => { + useAuthMock.mockReturnValue({ token: null }); + + render(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/app/home.tsx b/apps/mobile/src/app/home.tsx index c101b84..fd1b966 100644 --- a/apps/mobile/src/app/home.tsx +++ b/apps/mobile/src/app/home.tsx @@ -1,5 +1,5 @@ import Ionicons from '@expo/vector-icons/Ionicons'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Platform, Pressable, @@ -10,6 +10,8 @@ import { } from 'react-native'; import MapView, { Marker, Polyline, PROVIDER_GOOGLE } from 'react-native-maps'; import { useTranslation } from 'react-i18next'; +import { useAuth } from '../services/auth.service'; +import { API_URL, authJsonHeaders } from '../services/api.config'; type JobOnMap = { id: string; @@ -53,9 +55,58 @@ const JOBS_ON_MAP: JobOnMap[] = [ export default function HomeScreen() { const { t } = useTranslation(); + const { token } = useAuth(); + const [jobsOnMap, setJobsOnMap] = useState(JOBS_ON_MAP); + + useEffect(() => { + if (!token) return; + + (async () => { + try { + // Fetch jobs from backend + const res = await fetch(`${API_URL}/jobs`, { + method: 'GET', + headers: authJsonHeaders(token), + }); + + if (res.ok) { + const jobs = await res.json(); + + // Transform backend jobs to map format + const mappedJobs = jobs + .filter((job: any) => job.siteId) + .map((job: any, index: number) => { + // For now, generate coordinates near current location + // In production, this would come from site data + const offsetLat = (Math.random() - 0.5) * 0.05; + const offsetLng = (Math.random() - 0.5) * 0.05; + + return { + id: String(job.id), + name: job.rawAddress || `Job #${job.id}`, + distanceMi: Math.round(Math.random() * 5 * 10) / 10, + coordinate: { + latitude: CURRENT_LOCATION.latitude + offsetLat, + longitude: CURRENT_LOCATION.longitude + offsetLng, + }, + status: (job.status === 'IN_PROGRESS' ? 'active' : 'upcoming') as 'active' | 'upcoming', + }; + }); + + if (mappedJobs.length > 0) { + setJobsOnMap(mappedJobs); + } + } + } catch (error) { + console.error('Failed to fetch jobs:', error); + // Keep mock data on error + } + })(); + }, [token]); + const nextJob = useMemo( - () => [...JOBS_ON_MAP].sort((a, b) => a.distanceMi - b.distanceMi)[0], - [] + () => [...jobsOnMap].sort((a, b) => a.distanceMi - b.distanceMi)[0], + [jobsOnMap] ); const routeCoordinates = useMemo( @@ -88,7 +139,7 @@ export default function HomeScreen() { pinColor="#2563eb" /> - {JOBS_ON_MAP.map((job) => ( + {jobsOnMap.map((job) => ( - {t('home.jobs_visible', { count: JOBS_ON_MAP.length })} + {t('home.jobs_visible', { count: jobsOnMap.length })} diff --git a/apps/mobile/src/services/analytics-api.service.spec.ts b/apps/mobile/src/services/analytics-api.service.spec.ts new file mode 100644 index 0000000..529292b --- /dev/null +++ b/apps/mobile/src/services/analytics-api.service.spec.ts @@ -0,0 +1,73 @@ +import { + fetchAnalyticsEarnings, + fetchAnalyticsExpenses, + fetchAnalyticsProfitability, + fetchAnalyticsSummary, +} from './analytics-api.service'; + +const fetchMock = global.fetch as jest.Mock; + +describe('analytics-api.service', () => { + beforeEach(() => { + fetchMock.mockReset(); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('fetches analytics summary with auth headers', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ jobsTotal: 42, completionRate: 75 }), + }); + + await expect(fetchAnalyticsSummary('token-123')).resolves.toEqual({ + jobsTotal: 42, + completionRate: 75, + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/analytics/summary', + { + method: 'GET', + headers: { + Authorization: 'Bearer token-123', + 'Content-Type': 'application/json', + }, + } + ); + }); + + it('returns empty summary when the summary endpoint fails', async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 500 }); + + await expect(fetchAnalyticsSummary('token-123')).resolves.toEqual({}); + }); + + it.each([ + ['earnings', fetchAnalyticsEarnings, '/analytics/earnings'], + ['expenses', fetchAnalyticsExpenses, '/analytics/expenses'], + ['profitability', fetchAnalyticsProfitability, '/analytics/profitability'], + ])('fetches %s monthly metrics', async (_name, fetcher, path) => { + const metrics = [{ month: 'May', jobs: 9, revenue: 12 }]; + fetchMock.mockResolvedValueOnce({ ok: true, json: async () => metrics }); + + await expect(fetcher('token-123')).resolves.toEqual(metrics); + expect(fetchMock).toHaveBeenCalledWith( + `https://api.example.com/api${path}`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer token-123', + }), + }) + ); + }); + + it('returns an empty monthly list when a monthly endpoint fails', async () => { + fetchMock.mockRejectedValueOnce(new Error('network down')); + + await expect(fetchAnalyticsEarnings('token-123')).resolves.toEqual([]); + }); +}); diff --git a/apps/mobile/src/services/analytics-api.service.ts b/apps/mobile/src/services/analytics-api.service.ts new file mode 100644 index 0000000..e94369b --- /dev/null +++ b/apps/mobile/src/services/analytics-api.service.ts @@ -0,0 +1,97 @@ +import { authJsonHeaders, API_URL } from './api.config'; + +export type AnalyticsSummary = { + jobsTotal?: number; + jobsThisMonth?: number; + completionRate?: number; + avgResponseTime?: number; + faultRepeatRate?: number; + revenueTotal?: number; + profitabilityIndex?: number; +}; + +export type MonthlyMetric = { + month: string; + jobs: number; + revenue: number; +}; + +export async function fetchAnalyticsSummary( + token: string +): Promise { + try { + const res = await fetch(`${API_URL}/analytics/summary`, { + method: 'GET', + headers: authJsonHeaders(token), + }); + + if (!res.ok) { + throw new Error(`Failed to fetch analytics summary (${res.status})`); + } + + return await res.json(); + } catch (error) { + console.error('Analytics summary fetch error:', error); + return {}; + } +} + +export async function fetchAnalyticsEarnings( + token: string +): Promise { + try { + const res = await fetch(`${API_URL}/analytics/earnings`, { + method: 'GET', + headers: authJsonHeaders(token), + }); + + if (!res.ok) { + throw new Error(`Failed to fetch analytics earnings (${res.status})`); + } + + return await res.json(); + } catch (error) { + console.error('Analytics earnings fetch error:', error); + return []; + } +} + +export async function fetchAnalyticsExpenses( + token: string +): Promise { + try { + const res = await fetch(`${API_URL}/analytics/expenses`, { + method: 'GET', + headers: authJsonHeaders(token), + }); + + if (!res.ok) { + throw new Error(`Failed to fetch analytics expenses (${res.status})`); + } + + return await res.json(); + } catch (error) { + console.error('Analytics expenses fetch error:', error); + return []; + } +} + +export async function fetchAnalyticsProfitability( + token: string +): Promise { + try { + const res = await fetch(`${API_URL}/analytics/profitability`, { + method: 'GET', + headers: authJsonHeaders(token), + }); + + if (!res.ok) { + throw new Error(`Failed to fetch analytics profitability (${res.status})`); + } + + return await res.json(); + } catch (error) { + console.error('Analytics profitability fetch error:', error); + return []; + } +} diff --git a/apps/mobile/src/services/library-api.service.spec.ts b/apps/mobile/src/services/library-api.service.spec.ts new file mode 100644 index 0000000..cf207f6 --- /dev/null +++ b/apps/mobile/src/services/library-api.service.spec.ts @@ -0,0 +1,94 @@ +import { + fetchFaultDetails, + fetchLibraryModelDetails, + fetchLibraryModels, + searchLibrary, +} from './library-api.service'; + +const fetchMock = global.fetch as jest.Mock; + +describe('library-api.service', () => { + beforeEach(() => { + fetchMock.mockReset(); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('searches the library with an encoded query and auth headers', async () => { + const results = [ + { + id: 'fault-f22', + title: 'Fault Code F22', + category: 'Fault Codes', + type: 'fault', + }, + ]; + fetchMock.mockResolvedValueOnce({ ok: true, json: async () => results }); + + await expect(searchLibrary('token-123', 'Vaillant F22')).resolves.toEqual( + results + ); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/library/search?q=Vaillant%20F22', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer token-123', + }), + }) + ); + }); + + it('returns an empty search result list when search fails', async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 400 }); + + await expect(searchLibrary('token-123', 'F22')).resolves.toEqual([]); + }); + + it('fetches model lists and details', async () => { + const models = [ + { + id: 'model-1', + name: 'ecoTEC Plus', + brand: 'Vaillant', + category: 'Boiler', + }, + ]; + fetchMock + .mockResolvedValueOnce({ ok: true, json: async () => models }) + .mockResolvedValueOnce({ ok: true, json: async () => models[0] }); + + await expect(fetchLibraryModels('token-123')).resolves.toEqual(models); + await expect(fetchLibraryModelDetails('token-123', 'model-1')).resolves.toEqual( + models[0] + ); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.example.com/api/library/models', + expect.any(Object) + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.example.com/api/library/models/model-1', + expect.any(Object) + ); + }); + + it('fetches fault details and returns null when details fail', async () => { + const fault = { + code: 'F22', + title: 'Low pressure', + description: 'Low water pressure.', + }; + fetchMock + .mockResolvedValueOnce({ ok: true, json: async () => fault }) + .mockRejectedValueOnce(new Error('network down')); + + await expect(fetchFaultDetails('token-123', 'F22')).resolves.toEqual(fault); + await expect(fetchFaultDetails('token-123', 'F28')).resolves.toBeNull(); + }); +}); diff --git a/apps/mobile/src/services/library-api.service.ts b/apps/mobile/src/services/library-api.service.ts new file mode 100644 index 0000000..a035322 --- /dev/null +++ b/apps/mobile/src/services/library-api.service.ts @@ -0,0 +1,107 @@ +import { authJsonHeaders, API_URL } from './api.config'; + +export type LibraryModel = { + id: string; + name: string; + brand: string; + category: string; + description?: string; +}; + +export type LibraryFault = { + code: string; + title: string; + description: string; + solutions?: string[]; +}; + +export type LibrarySearchResult = { + id: string; + title: string; + category: string; + type: 'model' | 'fault' | 'guide'; + description?: string; +}; + +export async function searchLibrary( + token: string, + query: string +): Promise { + try { + const res = await fetch(`${API_URL}/library/search?q=${encodeURIComponent(query)}`, { + method: 'GET', + headers: authJsonHeaders(token), + }); + + if (!res.ok) { + throw new Error(`Failed to search library (${res.status})`); + } + + return await res.json(); + } catch (error) { + console.error('Library search error:', error); + return []; + } +} + +export async function fetchLibraryModels( + token: string +): Promise { + try { + const res = await fetch(`${API_URL}/library/models`, { + method: 'GET', + headers: authJsonHeaders(token), + }); + + if (!res.ok) { + throw new Error(`Failed to fetch library models (${res.status})`); + } + + return await res.json(); + } catch (error) { + console.error('Library models fetch error:', error); + return []; + } +} + +export async function fetchLibraryModelDetails( + token: string, + modelId: string +): Promise { + try { + const res = await fetch(`${API_URL}/library/models/${modelId}`, { + method: 'GET', + headers: authJsonHeaders(token), + }); + + if (!res.ok) { + throw new Error(`Failed to fetch model details (${res.status})`); + } + + return await res.json(); + } catch (error) { + console.error('Library model details fetch error:', error); + return null; + } +} + +export async function fetchFaultDetails( + token: string, + faultCode: string +): Promise { + try { + const res = await fetch(`${API_URL}/library/faults/${faultCode}`, { + method: 'GET', + headers: authJsonHeaders(token), + }); + + if (!res.ok) { + throw new Error(`Failed to fetch fault details (${res.status})`); + } + + return await res.json(); + } catch (error) { + console.error('Library fault details fetch error:', error); + return null; + } +} diff --git a/apps/mobile/src/test-setup.ts b/apps/mobile/src/test-setup.ts index f0173b7..60f1568 100644 --- a/apps/mobile/src/test-setup.ts +++ b/apps/mobile/src/test-setup.ts @@ -9,3 +9,36 @@ jest.mock('expo/src/winter/ImportMetaRegistry', () => ({ if (typeof global.structuredClone === 'undefined') { global.structuredClone = (object) => JSON.parse(JSON.stringify(object)); } + +(global as any).fetch = jest.fn(); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + multiGet: jest.fn(), + multiSet: jest.fn(), + multiRemove: jest.fn(), + clear: jest.fn(), +})); + +jest.mock('@expo/vector-icons/Ionicons', () => 'Icon'); + +jest.mock('react-native-maps', () => { + const React = require('react'); + const { View } = require('react-native'); + + const MockMapView = ({ children, ...props }: any) => + React.createElement(View, props, children); + const MockMarker = ({ children, ...props }: any) => + React.createElement(View, props, children); + const MockPolyline = (props: any) => React.createElement(View, props); + + return { + __esModule: true, + default: MockMapView, + Marker: MockMarker, + Polyline: MockPolyline, + PROVIDER_GOOGLE: 'google', + }; +}); From 505b191b64c90cf8722f0d556fa645baedab5186 Mon Sep 17 00:00:00 2001 From: alde23 Date: Mon, 1 Jun 2026 13:59:07 +0200 Subject: [PATCH 2/8] fix(ci): fix prisma schema path in github actions --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b25a84b..0066030 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,13 +36,13 @@ jobs: run: npm ci - name: Generate Prisma Client - run: npx prisma generate + run: npx prisma generate --schema apps/api/prisma/schema.prisma env: DATABASE_URL: postgresql://postgres:postgres@localhost:5433/a3service DIRECT_URL: postgresql://postgres:postgres@localhost:5433/a3service - name: Deploy Prisma Migrations - run: npx prisma migrate deploy + run: npx prisma migrate deploy --schema apps/api/prisma/schema.prisma env: DATABASE_URL: postgresql://postgres:postgres@localhost:5433/a3service DIRECT_URL: postgresql://postgres:postgres@localhost:5433/a3service From 62e63c2e150a836f66e560814387a0ae093b8cd5 Mon Sep 17 00:00:00 2001 From: alde23 Date: Mon, 1 Jun 2026 14:06:03 +0200 Subject: [PATCH 3/8] fix(ci): run prisma from api directory --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0066030..371c235 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,13 +36,13 @@ jobs: run: npm ci - name: Generate Prisma Client - run: npx prisma generate --schema apps/api/prisma/schema.prisma + run: cd apps/api && npx prisma generate env: DATABASE_URL: postgresql://postgres:postgres@localhost:5433/a3service DIRECT_URL: postgresql://postgres:postgres@localhost:5433/a3service - name: Deploy Prisma Migrations - run: npx prisma migrate deploy --schema apps/api/prisma/schema.prisma + run: cd apps/api && npx prisma migrate deploy env: DATABASE_URL: postgresql://postgres:postgres@localhost:5433/a3service DIRECT_URL: postgresql://postgres:postgres@localhost:5433/a3service From 92f086942b3a4ed02d3c8e309091b83498e05e19 Mon Sep 17 00:00:00 2001 From: alde23 Date: Mon, 1 Jun 2026 14:11:01 +0200 Subject: [PATCH 4/8] fix(ci): explicitly build shared-schema before api --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 371c235..0eed5c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: DIRECT_URL: postgresql://postgres:postgres@localhost:5433/a3service - name: Build API - run: npx nx build api + run: npx nx build shared-schema && npx nx build api - name: Run Tests run: npx nx run-many --target=test --all --coverage From 7cc4d3f5b029a81966739481b3e5e532305f508a Mon Sep 17 00:00:00 2001 From: alde23 Date: Mon, 1 Jun 2026 14:15:49 +0200 Subject: [PATCH 5/8] fix(mobile): remove redundant jest config file --- apps/mobile/jest.config.js | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 apps/mobile/jest.config.js diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js deleted file mode 100644 index a7956cc..0000000 --- a/apps/mobile/jest.config.js +++ /dev/null @@ -1,22 +0,0 @@ -/// -/// -module.exports = { - displayName: 'mobile', - preset: 'jest-expo', - moduleFileExtensions: ['ts', 'js', 'html', 'tsx', 'jsx'], - setupFilesAfterEnv: ['/src/test-setup.ts'], - moduleNameMapper: { - '[.]svg$': '@nx/expo/plugins/jest/svg-mock', - }, - transform: { - '[.][jt]sx?$': [ - 'babel-jest', - { - configFile: __dirname + '/.babelrc.js', - }, - ], - '^.+[.](bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp|ttf|otf|m4v|mov|mp4|mpeg|mpg|webm|aac|aiff|caf|m4a|mp3|wav|html|pdf|obj)$': - require.resolve('jest-expo/src/preset/assetFileTransformer.js'), - }, - coverageDirectory: '../../coverage/apps/mobile', -}; From 7b7758bd690b23331910ef164952e921aab84b74 Mon Sep 17 00:00:00 2001 From: alde23 Date: Mon, 1 Jun 2026 16:03:13 +0200 Subject: [PATCH 6/8] fix(mobile): resolve jest test timeouts and improve test coverage >70% --- apps/mobile/src/app/analytics.spec.tsx | 34 +++- apps/mobile/src/app/analytics.tsx | 14 +- .../mobile/src/services/auth.service.spec.tsx | 99 ++++++++++- .../src/services/jobs-sync.service.spec.ts | 110 ++++++++++++ .../src/services/unified-sync.service.spec.ts | 149 ++++++++++++---- .../repositories/catalog.repository.spec.ts | 100 +++++++++++ .../repositories/expenses.repository.spec.ts | 32 ++++ .../repositories/expenses.repository.ts | 6 +- .../repositories/jobs.repository.spec.ts | 165 ++++++++++++++++++ .../sync-queue.repository.spec.ts | 96 ++++++++++ 10 files changed, 757 insertions(+), 48 deletions(-) create mode 100644 apps/mobile/src/services/jobs-sync.service.spec.ts create mode 100644 apps/mobile/src/storage/repositories/catalog.repository.spec.ts create mode 100644 apps/mobile/src/storage/repositories/expenses.repository.spec.ts create mode 100644 apps/mobile/src/storage/repositories/jobs.repository.spec.ts create mode 100644 apps/mobile/src/storage/repositories/sync-queue.repository.spec.ts diff --git a/apps/mobile/src/app/analytics.spec.tsx b/apps/mobile/src/app/analytics.spec.tsx index ecbf394..c63b3a6 100644 --- a/apps/mobile/src/app/analytics.spec.tsx +++ b/apps/mobile/src/app/analytics.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, waitFor } from '@testing-library/react-native'; +import { render, waitFor, fireEvent } from '@testing-library/react-native'; import AnalyticsScreen from './analytics'; import { useAuth } from '../services/auth.service'; @@ -17,6 +17,16 @@ jest.mock('../services/analytics-api.service', () => ({ fetchAnalyticsEarnings: jest.fn(), })); +jest.mock('../storage/repositories/expenses.repository', () => ({ + observeExpenses: jest.fn().mockReturnValue({ + subscribe: jest.fn((cb) => { + cb([{ amount: 50 }]); + return { unsubscribe: jest.fn() }; + }), + }), + addExpense: jest.fn(), +})); + jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: { count?: string | number }) => { @@ -97,4 +107,26 @@ describe('AnalyticsScreen backend integration', () => { expect(fetchSummaryMock).not.toHaveBeenCalled(); expect(fetchEarningsMock).not.toHaveBeenCalled(); }); + + it('can open modal and add an expense', async () => { + const { getByText, getByPlaceholderText } = render(); + + // Open modal + const addExpenseBtn = await waitFor(() => getByText('+ Add Expense')); + fireEvent.press(addExpenseBtn); + + // Modal is open, type values + const amountInput = getByPlaceholderText('Amount'); + const descInput = getByPlaceholderText('Description'); + fireEvent.changeText(amountInput, '120.5'); + fireEvent.changeText(descInput, 'Gasoline'); + + // Click Save + const saveBtn = getByText('Save'); + fireEvent.press(saveBtn); + + await waitFor(() => { + expect(require('../storage/repositories/expenses.repository').addExpense).toHaveBeenCalledWith(120.5, 'Gasoline'); + }); + }); }); diff --git a/apps/mobile/src/app/analytics.tsx b/apps/mobile/src/app/analytics.tsx index e8eb862..6216934 100644 --- a/apps/mobile/src/app/analytics.tsx +++ b/apps/mobile/src/app/analytics.tsx @@ -33,12 +33,6 @@ type MonthlyPoint = { revenue: number; }; -const METRICS: MetricCard[] = [ - { label: 'Jobs this year', value: '1,248', delta: '+18%', tone: 'blue' }, - { label: 'Completed on first visit', value: '72%', delta: '+6%', tone: 'emerald' }, - { label: 'Fault repeat rate', value: '8.4%', delta: '-2%', tone: 'amber' }, - { label: 'Avg. response time', value: '34m', delta: '-8m', tone: 'slate' }, -]; const MONTHLY_DATA: MonthlyPoint[] = [ { month: 'Jan', jobs: 74, revenue: 18 }, @@ -144,10 +138,6 @@ export default function AnalyticsScreen() { () => monthlyData.reduce((sum, point) => sum + point.revenue, 0) || summary.revenueTotal || 0, [monthlyData, summary] ); - const peakMonth = useMemo( - () => [...monthlyData].sort((a, b) => b.jobs - a.jobs)[0] || { month: 'N/A', jobs: 0, revenue: 0 }, - [monthlyData] - ); const maxJobs = Math.max(...monthlyData.map((point) => point.jobs), 1); @@ -236,12 +226,12 @@ export default function AnalyticsScreen() { Net Revenue - {revenueTotal - expensesSum} + {revenueTotal - expensesSum} KM Total Expenses - {expensesSum} + {expensesSum} KM diff --git a/apps/mobile/src/services/auth.service.spec.tsx b/apps/mobile/src/services/auth.service.spec.tsx index ae9b2b1..912510b 100644 --- a/apps/mobile/src/services/auth.service.spec.tsx +++ b/apps/mobile/src/services/auth.service.spec.tsx @@ -51,11 +51,18 @@ const TestComponent = () => { describe('AuthService', () => { beforeEach(() => { jest.clearAllMocks(); + global.fetch = jest.fn(); }); it('loads offline token from SecureStore', async () => { (SecureStore.getItemAsync as jest.Mock).mockResolvedValue('offline-token'); (AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify({ username: 'offline-user' })); + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ username: 'online-user' }) + }); const { getByTestId, queryByText } = render( @@ -66,7 +73,8 @@ describe('AuthService', () => { expect(queryByText('Loading...')).toBeTruthy(); await waitFor(() => { - expect(getByTestId('user').props.children).toBe('offline-user'); + // It should initially be offline-user, but fetch will update to online-user + expect(getByTestId('user').props.children).toBe('online-user'); expect(getByTestId('token').props.children).toBe('offline-token'); }); }); @@ -86,4 +94,93 @@ describe('AuthService', () => { expect(getByTestId('token').props.children).toBe('NoToken'); }); }); + + it('handles unauthorized /auth/me and refreshes', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue('expired-token'); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify({ username: 'offline-user' })); + + (global.fetch as jest.Mock).mockImplementation(async (url) => { + if (url.includes('/auth/me')) { + return { ok: false, status: 401 }; + } + if (url.includes('/auth/refresh')) { + return { + ok: true, + json: async () => ({ token: 'new-token', refreshToken: 'new-refresh' }) + }; + } + }); + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId('token').props.children).toBe('new-token'); + }); + }); + + it('logs out successfully', async () => { + const TestLogoutComponent = () => { + const auth = useAuth(); + return ( + + {auth.token ?? 'NoToken'} + Logout + + ); + }; + + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue('offline-token'); + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId('token').props.children).toBe('offline-token'); + }); + + getByTestId('logout').props.onPress(); + + await waitFor(() => { + expect(getByTestId('token').props.children).toBe('NoToken'); + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('A3S_AUTH_ACCESS_TOKEN'); + expect(AsyncStorage.removeItem).toHaveBeenCalledWith('A3S_AUTH_USER'); + }); + }); + + it('logs in successfully', async () => { + const TestLoginComponent = () => { + const auth = useAuth(); + return ( + + {auth.token ?? 'NoToken'} + auth.login('test@a3.local', 'pass')}>Login + + ); + }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ token: 'login-token', user: { username: 'test-user' } }) + }); + + const { getByTestId } = render( + + + + ); + + getByTestId('login').props.onPress(); + + await waitFor(() => { + expect(getByTestId('token').props.children).toBe('login-token'); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith('A3S_AUTH_ACCESS_TOKEN', 'login-token'); + }); + }); }); diff --git a/apps/mobile/src/services/jobs-sync.service.spec.ts b/apps/mobile/src/services/jobs-sync.service.spec.ts new file mode 100644 index 0000000..fbc8b01 --- /dev/null +++ b/apps/mobile/src/services/jobs-sync.service.spec.ts @@ -0,0 +1,110 @@ +import { syncJobsWithServer, pullJobsFromServer } from './jobs-sync.service'; +import { database } from '../storage'; + +jest.mock('../storage/repositories/sync-queue.repository', () => ({ + listPendingSyncOperations: jest.fn().mockResolvedValue([]), + markSyncOperationFailed: jest.fn(), + markSyncOperationSynced: jest.fn(), + markSyncOperationSyncedInCurrentWriter: jest.fn(), +})); + +describe('jobs-sync.service', () => { + beforeEach(async () => { + await database.write(async () => { + await database.get('jobs').query().destroyAllPermanently(); + }); + jest.restoreAllMocks(); + global.fetch = jest.fn(); + }); + + describe('pullJobsFromServer', () => { + it('throws error if response is not ok', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect(pullJobsFromServer('token-123')).rejects.toThrow('Pull jobs failed (500)'); + }); + + it('throws error if response is not an array', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 1 }), + }); + + await expect(pullJobsFromServer('token-123')).rejects.toThrow('Pull jobs response is not an array'); + }); + + it('pulls jobs from server and upserts them', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + id: 'job-1', + siteId: 'site-1', + technicianId: 'tech-1', + scheduledDate: new Date().toISOString(), + estimatedDuration: 60, + status: 'PENDING', + notes: 'Test note', + rawAddress: 'Test address', + }, + ], + }); + + const count = await pullJobsFromServer('token-123'); + expect(count).toBe(1); + + const jobs = await database.get('jobs').query().fetch(); + expect(jobs.length).toBe(1); + }); + }); + + describe('syncJobsWithServer (pushPendingQueue)', () => { + it('returns a sync summary', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => [], + }); + + const summary = await syncJobsWithServer('token-123'); + expect(summary.pulled).toBe(0); + expect(summary.pushed).toBe(0); + expect(summary.failed).toBe(0); + }); + + it('pushes pending operations', async () => { + await database.write(async () => { + await database.get('jobs').create((job: any) => { + job._raw.id = 'l1'; + job.siteId = 'site-1'; + }); + }); + + const syncQueueMock = require('../storage/repositories/sync-queue.repository'); + syncQueueMock.listPendingSyncOperations.mockResolvedValueOnce([ + { id: 'q-1', tableName: 'jobs', operation: 'INSERT', payload: JSON.stringify({ localJobId: 'l1', rawAddress: 'address' }), recordId: 'l1' }, + { id: 'q-2', tableName: 'jobs', operation: 'UPDATE', payload: JSON.stringify({ remoteId: 'r1', status: 'COMPLETED' }), recordId: 'l2' }, + { id: 'q-invalid', tableName: 'other', operation: 'INSERT', payload: '{}' }, + ]); + + (global.fetch as jest.Mock).mockImplementation((url, options) => { + if (options.method === 'GET') { + return Promise.resolve({ ok: true, json: async () => [] }); + } + if (options.method === 'POST') { + return Promise.resolve({ ok: true, json: async () => ({ id: 'r2' }) }); + } + if (options.method === 'PATCH') { + return Promise.resolve({ ok: true }); + } + return Promise.resolve({ ok: false }); + }); + + const summary = await syncJobsWithServer('token-123'); + expect(summary.pushed).toBe(2); + expect(summary.failed).toBe(1); + }); + }); +}); diff --git a/apps/mobile/src/services/unified-sync.service.spec.ts b/apps/mobile/src/services/unified-sync.service.spec.ts index 1ecdc97..e53bd5f 100644 --- a/apps/mobile/src/services/unified-sync.service.spec.ts +++ b/apps/mobile/src/services/unified-sync.service.spec.ts @@ -6,13 +6,18 @@ import Part from '../storage/models/Part'; import ServiceLog from '../storage/models/ServiceLog'; import SyncLog from '../storage/models/SyncLog'; import SyncConflict from '../storage/models/SyncConflict'; +import * as JobsSyncService from './jobs-sync.service'; + +jest.mock('./jobs-sync.service', () => ({ + pullJobsFromServer: jest.fn().mockResolvedValue(0), + syncJobsWithServer: jest.fn().mockResolvedValue({ pulled: 0, pushed: 0, failed: 0 }), +})); const fetchMock = global.fetch as jest.Mock; describe('unified-sync.service', () => { beforeEach(async () => { fetchMock.mockReset(); - jest.spyOn(console, 'error').mockImplementation(() => undefined); jest.spyOn(console, 'warn').mockImplementation(() => undefined); // Clear local SQLite tables between runs @@ -24,6 +29,11 @@ describe('unified-sync.service', () => { await database.get('boiler_models').query().destroyAllPermanently(); await database.get('parts').query().destroyAllPermanently(); await database.get('fault_codes').query().destroyAllPermanently(); + await database.get('app_preferences').query().destroyAllPermanently(); + await database.get('expenses').query().destroyAllPermanently(); + await database.get('warranties').query().destroyAllPermanently(); + await database.get('labor_entries').query().destroyAllPermanently(); + await database.get('consumed_parts').query().destroyAllPermanently(); }); }); @@ -60,21 +70,11 @@ describe('unified-sync.service', () => { }); it('pushes pending transactional queue in proper dependency order and records sync status', async () => { - // 1. Mock API calls: - // A. Mock jobs pull - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => [], - }); - // B. Mock catalog pull - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ items: [] }), - }); - // C. Mock ServiceLog Push - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 'remote-log-123' }), + // 1. Mock API calls + fetchMock.mockImplementation((url, options) => { + if (url.includes('/library/models')) return Promise.resolve({ ok: true, json: async () => ({ items: [] }) }); + if (options?.method === 'POST') return Promise.resolve({ ok: true, json: async () => ({ id: 'remote-log-123' }) }); + return Promise.resolve({ ok: true, json: async () => ({}) }); }); // 2. Setup mock local record and enqueue insert sync operation @@ -95,21 +95,10 @@ describe('unified-sync.service', () => { }); it('handles conflict (409) and writes to sync_conflicts table offline', async () => { - // A. Mock jobs pull - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => [], - }); - // B. Mock catalog pull - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ items: [] }), - }); - // C. Mock ServiceLog Push with 409 Conflict status - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 409, - json: async () => ({ error: 'Conflict found on job-abc' }), + fetchMock.mockImplementation((url, options) => { + if (url.includes('/library/models')) return Promise.resolve({ ok: true, json: async () => ({ items: [] }) }); + if (options?.method === 'POST') return Promise.resolve({ ok: false, status: 409, json: async () => ({ error: 'Conflict' }) }); + return Promise.resolve({ ok: true, json: async () => ({}) }); }); await createServiceLog('job-abc', 'Completed service', 'All done'); @@ -124,4 +113,102 @@ describe('unified-sync.service', () => { expect(conflicts[0].affectedEntity).toBe('service_logs'); expect(conflicts[0].status).toBe('OPEN'); }); + + it('pushes expenses (INSERT, UPDATE, DELETE) successfully', async () => { + fetchMock.mockImplementation((url, options) => { + if (url.includes('/library/models')) return Promise.resolve({ ok: true, json: async () => ({ items: [] }) }); + if (options?.method === 'POST') return Promise.resolve({ ok: true, json: async () => ({ id: 'remote-' + Math.random() }) }); + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + + await database.write(async () => { + const exp1 = await database.get('expenses').create((rec: any) => { + rec.jobId = 'j1'; + rec.amount = 100; + rec.currency = 'USD'; + rec.description = 'Tolls'; + }); + await database.get('sync_queue').create((queue: any) => { + queue.tableName = 'expenses'; + queue.recordId = exp1.id; + queue.operation = 'INSERT'; + queue.payload = JSON.stringify({ jobId: 'j1', amount: 100, currency: 'USD', description: 'Tolls' }); + queue.status = 'pending'; + }); + + const exp2 = await database.get('expenses').create((rec: any) => { rec.remoteId = 'rem2'; rec.jobId = 'j1'; rec.amount = 200; }); + await database.get('sync_queue').create((queue: any) => { + queue.tableName = 'expenses'; + queue.recordId = exp2.id; + queue.operation = 'UPDATE'; + queue.payload = JSON.stringify({ remoteId: 'rem2', amount: 250 }); + queue.status = 'pending'; + }); + + const exp3 = await database.get('expenses').create((rec: any) => { rec.remoteId = 'rem3'; rec.jobId = 'j1'; rec.amount = 300; }); + await database.get('sync_queue').create((queue: any) => { + queue.tableName = 'expenses'; + queue.recordId = exp3.id; + queue.operation = 'DELETE'; + queue.payload = JSON.stringify({ remoteId: 'rem3' }); + queue.status = 'pending'; + }); + }); + + const summary = await executeUnifiedSync('token-123'); + console.log('EXPENSES SUMMARY:', summary); + expect(summary.recordsPushed).toBe(3); + expect(summary.failures).toBe(0); + }); + + it('pushes warranties, labor entries, and consumed parts', async () => { + fetchMock.mockImplementation((url, options) => { + if (url.includes('/library/models')) return Promise.resolve({ ok: true, json: async () => ({ items: [] }) }); + if (options?.method === 'POST') return Promise.resolve({ ok: true, json: async () => ({ id: 'remote-' + Math.random() }) }); + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + + await database.write(async () => { + const w = await database.get('warranties').create((rec: any) => { rec.jobId = 'j1'; }); + await database.get('sync_queue').create((q: any) => { + q.tableName = 'warranties'; + q.recordId = w.id; + q.operation = 'INSERT'; + q.payload = JSON.stringify({ jobId: 'j1' }); + q.status = 'pending'; + }); + + const log = await database.get('service_logs').create((rec: any) => { rec.jobId = 'j1'; }); + await database.get('sync_queue').create((q: any) => { + q.tableName = 'service_logs'; + q.recordId = log.id; + q.operation = 'INSERT'; + q.payload = JSON.stringify({ jobId: 'j1' }); + q.status = 'pending'; + }); + + const labor = await database.get('labor_entries').create((rec: any) => { rec.serviceLogId = log.id; }); + await database.get('sync_queue').create((q: any) => { + q.tableName = 'labor_entries'; + q.recordId = labor.id; + q.operation = 'INSERT'; + q.payload = JSON.stringify({ serviceLogId: log.id, hours: 2 }); + q.status = 'pending'; + }); + + const part = await database.get('consumed_parts').create((rec: any) => { rec.serviceLogId = log.id; }); + await database.get('sync_queue').create((q: any) => { + q.tableName = 'consumed_parts'; + q.recordId = part.id; + q.operation = 'INSERT'; + q.payload = JSON.stringify({ serviceLogId: log.id, partId: 'p1' }); + q.status = 'pending'; + }); + }); + + const summary = await executeUnifiedSync('token-123'); + console.log('WARRANTIES SUMMARY:', summary); + expect(summary.recordsPushed).toBe(4); + expect(summary.failures).toBe(0); + }); }); diff --git a/apps/mobile/src/storage/repositories/catalog.repository.spec.ts b/apps/mobile/src/storage/repositories/catalog.repository.spec.ts new file mode 100644 index 0000000..e012687 --- /dev/null +++ b/apps/mobile/src/storage/repositories/catalog.repository.spec.ts @@ -0,0 +1,100 @@ +import { + searchLocalBoilerModels, + searchLocalParts, + searchLocalFaultCodes, + getReferenceTableForModel, + upsertCatalogFromServer, +} from './catalog.repository'; +import { database } from '../index'; +import BoilerModel from '../models/BoilerModel'; + +describe('catalog.repository', () => { + beforeEach(async () => { + await database.write(async () => { + await database.get('boiler_models').query().destroyAllPermanently(); + await database.get('parts').query().destroyAllPermanently(); + await database.get('fault_codes').query().destroyAllPermanently(); + await database.get('technical_properties').query().destroyAllPermanently(); + await database.get('reference_tables').query().destroyAllPermanently(); + }); + }); + + it('upserts boiler models and searches them', async () => { + await upsertCatalogFromServer('boiler_models', [ + { id: 'rm1', modelName: 'TestModel1', series: 'SeriesA' }, + { id: 'rm2', modelName: 'TestModel2', series: 'SeriesB' }, + ]); + + const resultsEmpty = await searchLocalBoilerModels(''); + expect(resultsEmpty.length).toBe(2); + + const results = await searchLocalBoilerModels('TestModel1'); + expect(results.length).toBe(1); + expect(results[0].modelName).toBe('TestModel1'); + }); + + it('upserts parts and searches them', async () => { + await upsertCatalogFromServer('parts', [ + { id: 'p1', sku: 'SKU1', name: 'PartA', brand: 'BrandA', unitPrice: 10 }, + { id: 'p2', sku: 'SKU2', name: 'PartB', brand: 'BrandB', unitPrice: '20' }, + ]); + + const resultsEmpty = await searchLocalParts(''); + expect(resultsEmpty.length).toBe(2); + + const results = await searchLocalParts('PartA'); + expect(results.length).toBe(1); + expect(results[0].sku).toBe('SKU1'); + + // Update existing + await upsertCatalogFromServer('parts', [ + { id: 'p1', sku: 'SKU1', name: 'PartA_Updated', brand: 'BrandA', unitPrice: 15 }, + ]); + const resultsUpdate = await searchLocalParts('PartA'); + expect(resultsUpdate.length).toBe(1); + expect(resultsUpdate[0].name).toBe('PartA_Updated'); + }); + + it('upserts fault codes and searches them', async () => { + await upsertCatalogFromServer('fault_codes', [ + { id: 'fc1', code: 'F1', title: 'Fault 1' }, + ]); + + const resultsEmpty = await searchLocalFaultCodes(''); + expect(resultsEmpty.length).toBe(1); + + const results = await searchLocalFaultCodes('F1'); + expect(results.length).toBe(1); + expect(results[0].code).toBe('F1'); + }); + + it('upserts technical properties and reference tables', async () => { + await upsertCatalogFromServer('technical_properties', [ + { id: 'prop1', code: 'P1', label: 'Prop 1' }, + ]); + await upsertCatalogFromServer('reference_tables', [ + { id: 'ref1', boilerModelId: 'bm1', propertyId: 'prop1', minValue: 10, maxValue: '20', required: true }, + ]); + + // get properties + const collection = database.get('technical_properties'); + const props = await collection.query().fetch(); + expect(props.length).toBe(1); + + // wait, we need local prop1 id + const localPropId = props[0].id; + + // update ref to use local prop id + await database.write(async () => { + const refs = await database.get('reference_tables').query().fetch(); + await (refs[0] as any).update((record: any) => { + record.propertyId = localPropId; + record.boilerModelId = 'local-bm1'; + }); + }); + + const results = await getReferenceTableForModel('local-bm1'); + expect(results.length).toBe(1); + expect(results[0].code).toBe('P1'); + }); +}); diff --git a/apps/mobile/src/storage/repositories/expenses.repository.spec.ts b/apps/mobile/src/storage/repositories/expenses.repository.spec.ts new file mode 100644 index 0000000..1746acf --- /dev/null +++ b/apps/mobile/src/storage/repositories/expenses.repository.spec.ts @@ -0,0 +1,32 @@ +import { addExpense } from './expenses.repository'; +import { database } from '../index'; +import Expense from '../models/Expense'; + +describe('expenses.repository', () => { + beforeEach(async () => { + await database.write(async () => { + await database.get('expenses').query().destroyAllPermanently(); + await database.get('sync_queue').query().destroyAllPermanently(); + }); + }); + + describe('addExpense', () => { + it('creates expense with default currency (BAM)', async () => { + await addExpense(100, 'Test Description'); + + const expenses = await database.get('expenses').query().fetch(); + expect(expenses.length).toBe(1); + expect(expenses[0].amount).toBe(100); + expect(expenses[0].currency).toBe('BAM'); + }); + + it('creates expense with custom currency', async () => { + await addExpense(200, 'Tolls', 'EUR'); + + const expenses = await database.get('expenses').query().fetch(); + expect(expenses.length).toBe(1); + expect(expenses[0].amount).toBe(200); + expect(expenses[0].currency).toBe('EUR'); + }); + }); +}); diff --git a/apps/mobile/src/storage/repositories/expenses.repository.ts b/apps/mobile/src/storage/repositories/expenses.repository.ts index 30e87da..3f62117 100644 --- a/apps/mobile/src/storage/repositories/expenses.repository.ts +++ b/apps/mobile/src/storage/repositories/expenses.repository.ts @@ -1,8 +1,8 @@ import { database } from '../index'; import Expense from '../models/Expense'; -import { enqueueSyncOperation } from './sync-queue.repository'; +import { enqueueSyncOperationInCurrentWriter } from './sync-queue.repository'; -export async function addExpense(amount: number, description: string, currency = 'USD') { +export async function addExpense(amount: number, description: string, currency = 'BAM') { await database.write(async () => { const expense = await database.get('expenses').create((record) => { record.amount = amount; @@ -11,7 +11,7 @@ export async function addExpense(amount: number, description: string, currency = record.incurredAt = new Date(); }); - await enqueueSyncOperation({ + await enqueueSyncOperationInCurrentWriter({ tableName: 'expenses', recordId: expense.id, operation: 'INSERT', diff --git a/apps/mobile/src/storage/repositories/jobs.repository.spec.ts b/apps/mobile/src/storage/repositories/jobs.repository.spec.ts new file mode 100644 index 0000000..5a7105a --- /dev/null +++ b/apps/mobile/src/storage/repositories/jobs.repository.spec.ts @@ -0,0 +1,165 @@ +import { + mapApiJobStatusToLocal, + mapLocalJobStatusToApi, + ensureJobsSeeded, + upsertJobsFromServer, + createJob, + updateJobStatus, + findJobByRemoteId, + setJobRemoteIdInCurrentWriter, +} from './jobs.repository'; +import { database } from '../index'; +import Job from '../models/Job'; + +describe('jobs.repository', () => { + beforeEach(async () => { + await database.write(async () => { + await database.get('jobs').query().destroyAllPermanently(); + await database.get('sync_queue').query().destroyAllPermanently(); + }); + }); + + describe('mappers', () => { + it('maps API to local', () => { + expect(mapApiJobStatusToLocal('IN_PROGRESS')).toBe('in-progress'); + expect(mapApiJobStatusToLocal('COMPLETED')).toBe('completed'); + expect(mapApiJobStatusToLocal('CANCELLED')).toBe('cancelled'); + expect(mapApiJobStatusToLocal('PENDING')).toBe('not-started'); + expect(mapApiJobStatusToLocal(null)).toBe('not-started'); + expect(mapApiJobStatusToLocal('UNKNOWN')).toBe('not-started'); + }); + + it('maps local to API', () => { + expect(mapLocalJobStatusToApi('in-progress')).toBe('IN_PROGRESS'); + expect(mapLocalJobStatusToApi('completed')).toBe('COMPLETED'); + expect(mapLocalJobStatusToApi('cancelled')).toBe('CANCELLED'); + expect(mapLocalJobStatusToApi('not-started')).toBe('PENDING'); + }); + }); + + describe('ensureJobsSeeded', () => { + it('seeds jobs only if empty', async () => { + await ensureJobsSeeded(); + const count1 = await database.get('jobs').query().fetchCount(); + expect(count1).toBe(3); + + await ensureJobsSeeded(); + const count2 = await database.get('jobs').query().fetchCount(); + expect(count2).toBe(3); // no change + }); + }); + + describe('upsertJobsFromServer', () => { + it('upserts jobs successfully', async () => { + const changed = await upsertJobsFromServer([ + { + id: 'remote-1', + siteId: 'site-1', + scheduledDate: new Date().toISOString(), + estimatedDuration: 120, + status: 'PENDING', + rawAddress: '123 Main St', + }, + ]); + + expect(changed).toBe(1); + let jobs = await database.get('jobs').query().fetch(); + expect(jobs.length).toBe(1); + expect(jobs[0].title).toBe('123 Main St'); + expect(jobs[0].durationMinutes).toBe(120); + + // Upsert existing + const changed2 = await upsertJobsFromServer([ + { + id: 'remote-1', + siteId: 'site-1', + scheduledDate: new Date().toISOString(), + estimatedDuration: 60, + status: 'COMPLETED', + rawAddress: '123 Main St Update', + }, + ]); + expect(changed2).toBe(1); + jobs = await database.get('jobs').query().fetch(); + expect(jobs.length).toBe(1); + expect(jobs[0].title).toBe('123 Main St Update'); + expect(jobs[0].status).toBe('completed'); + }); + }); + + describe('createJob', () => { + it('creates job and queues insert sync operation', async () => { + await createJob({ + title: 'New Job', + scheduledAt: new Date(), + durationMinutes: 45, + technicianId: 't1', + clientId: 'client-local', + }); + + const jobs = await database.get('jobs').query().fetch(); + expect(jobs.length).toBe(1); + expect(jobs[0].title).toBe('New Job'); + + const queue = await database.get('sync_queue').query().fetch(); + expect(queue.length).toBe(1); + const q = queue[0] as any; + expect(q.tableName).toBe('jobs'); + expect(q.operation).toBe('INSERT'); + }); + }); + + describe('updateJobStatus', () => { + it('updates status and queues sync operation', async () => { + await createJob({ + title: 'New Job', + scheduledAt: new Date(), + durationMinutes: 45, + technicianId: 't1', + clientId: 'client-local', + }); + + const jobs = await database.get('jobs').query().fetch(); + const job = jobs[0]; + + await updateJobStatus(job.id, 'completed'); + + const updated = await database.get('jobs').find(job.id); + expect(updated.status).toBe('completed'); + + const queue = await database.get('sync_queue').query().fetch(); + // createJob queued 1, updateJobStatus queued 1 + expect(queue.length).toBe(2); + expect((queue[1] as any).operation).toBe('INSERT'); // because no remote ID yet + }); + + it('queues UPDATE sync operation if remote ID exists', async () => { + await createJob({ + title: 'New Job', + scheduledAt: new Date(), + durationMinutes: 45, + technicianId: 't1', + clientId: 'client-local', + }); + + const jobs = await database.get('jobs').query().fetch(); + const job = jobs[0]; + + await database.write(async () => { + await setJobRemoteIdInCurrentWriter(job.id, 'rem-id'); + }); + + await updateJobStatus(job.id, 'completed'); + + const queue = await database.get('sync_queue').query().fetch(); + expect((queue[1] as any).operation).toBe('UPDATE'); + }); + }); + + describe('findJobByRemoteId', () => { + it('returns null if not found', async () => { + const job = await findJobByRemoteId('non-existent'); + expect(job).toBeNull(); + }); + }); +}); diff --git a/apps/mobile/src/storage/repositories/sync-queue.repository.spec.ts b/apps/mobile/src/storage/repositories/sync-queue.repository.spec.ts new file mode 100644 index 0000000..8de1958 --- /dev/null +++ b/apps/mobile/src/storage/repositories/sync-queue.repository.spec.ts @@ -0,0 +1,96 @@ +import { + enqueueSyncOperation, + listPendingSyncOperations, + markSyncOperationSynced, + markSyncOperationFailed, + retryFailedOperations, +} from './sync-queue.repository'; +import { database } from '../index'; + +describe('sync-queue.repository', () => { + beforeEach(async () => { + await database.write(async () => { + await database.get('sync_queue').query().destroyAllPermanently(); + }); + }); + + it('enqueues and lists pending operations', async () => { + await enqueueSyncOperation({ + tableName: 'jobs', + recordId: 'r1', + operation: 'INSERT', + payload: '{}', + }); + + const pending = await listPendingSyncOperations(10); + expect(pending.length).toBe(1); + expect(pending[0].recordId).toBe('r1'); + expect(pending[0].status).toBe('pending'); + }); + + it('marks synced', async () => { + await enqueueSyncOperation({ + tableName: 'jobs', + recordId: 'r1', + operation: 'INSERT', + payload: '{}', + }); + const pending = await listPendingSyncOperations(10); + const id = pending[0].id; + + await markSyncOperationSynced(id); + + const updated = await database.get('sync_queue').find(id); + expect((updated as any).status).toBe('synced'); + }); + + it('marks failed', async () => { + await enqueueSyncOperation({ + tableName: 'jobs', + recordId: 'r1', + operation: 'INSERT', + payload: '{}', + }); + const pending = await listPendingSyncOperations(10); + const id = pending[0].id; + + await markSyncOperationFailed(id); + + const updated = await database.get('sync_queue').find(id); + expect((updated as any).status).toBe('failed'); + expect((updated as any).retries).toBe(1); + }); + + it('retries failed operations', async () => { + await enqueueSyncOperation({ + tableName: 'jobs', + recordId: 'r1', + operation: 'INSERT', + payload: '{}', + }); + const pending = await listPendingSyncOperations(10); + const id = pending[0].id; + + await markSyncOperationFailed(id); // try 1 + + const retried = await retryFailedOperations(3); + expect(retried).toBe(1); + + const updated = await database.get('sync_queue').find(id); + expect((updated as any).status).toBe('pending'); + }); + + it('does not retry if max retries exceeded', async () => { + await enqueueSyncOperation({ + tableName: 'jobs', + recordId: 'r1', + operation: 'INSERT', + payload: '{}', + status: 'failed', + retries: 3, + }); + + const retried = await retryFailedOperations(3); + expect(retried).toBe(0); + }); +}); From 71817417b4ccc3fc45e549f0585a0a1e63aeeaaa Mon Sep 17 00:00:00 2001 From: alde23 Date: Mon, 1 Jun 2026 16:17:59 +0200 Subject: [PATCH 7/8] chore(api): add missing test files to hit 70 percent coverage --- .../commissioning.service.spec.ts | 138 ++++++++-- apps/api/src/app/jobs/jobs.service.spec.ts | 154 +++++++++-- .../app/scheduling/scheduling.service.spec.ts | 99 ++++++- .../service-logs/service-logs.service.spec.ts | 156 +++++++---- .../app/sync/sync-conflicts.service.spec.ts | 161 +++++++---- .../src/app/warranty/warranty.service.spec.ts | 251 +++++++++++++++--- apps/api/src/auth/auth.service.spec.ts | 151 ++++++++--- apps/api/src/rbac/roles.guard.spec.ts | 50 ++++ 8 files changed, 944 insertions(+), 216 deletions(-) create mode 100644 apps/api/src/rbac/roles.guard.spec.ts diff --git a/apps/api/src/app/commissioning/commissioning.service.spec.ts b/apps/api/src/app/commissioning/commissioning.service.spec.ts index 3c0cf04..2064322 100644 --- a/apps/api/src/app/commissioning/commissioning.service.spec.ts +++ b/apps/api/src/app/commissioning/commissioning.service.spec.ts @@ -24,36 +24,120 @@ describe('CommissioningService', () => { service = new CommissioningService(prisma as unknown as PrismaService); }); - it('flags missing required readings and out-of-range values', async () => { - prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' }); - prisma.referenceTable.findMany.mockResolvedValue([ - { - boilerModelId: 'model-1', - required: true, - minValue: new Prisma.Decimal(10), - maxValue: new Prisma.Decimal(20), - property: { code: 'pressure', label: 'Pressure', unit: 'bar' }, - }, - ]); - - const result = await service.validate({ - modelId: 'model-1', - readings: [{ code: 'pressure', value: 25 }], - }); - - expect(result.valid).toBe(false); - expect(result.issues[0].status).toBe('OUT_OF_RANGE'); + describe('getReference', () => { + it('throws if model not found', async () => { + prisma.boilerModel.findFirst.mockResolvedValue(null); + await expect(service.getReference('model-1')).rejects.toThrow('Boiler model not found'); + }); + + it('returns formatted references', async () => { + prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' }); + prisma.referenceTable.findMany.mockResolvedValue([ + { + boilerModelId: 'model-1', + required: true, + minValue: new Prisma.Decimal(10), + maxValue: new Prisma.Decimal(20), + property: { code: 'pressure', label: 'Pressure', unit: 'bar' }, + }, + ]); + const result = await service.getReference('model-1'); + expect(result.items.length).toBe(1); + expect(result.items[0].code).toBe('pressure'); + expect(result.items[0].min).toBe('10.00'); + }); }); - it('rejects invalid reading payloads', async () => { - prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' }); - prisma.referenceTable.findMany.mockResolvedValue([]); + describe('validate', () => { + it('throws if modelId is missing', async () => { + await expect(service.validate({ readings: [] } as any)).rejects.toThrow('modelId is required'); + }); + + it('throws if readings missing or not an array', async () => { + await expect(service.validate({ modelId: 'm1' } as any)).rejects.toThrow('readings must be an array'); + }); + + it('throws if model not found', async () => { + prisma.boilerModel.findFirst.mockResolvedValue(null); + await expect(service.validate({ modelId: 'm1', readings: [] })).rejects.toThrow('Boiler model not found'); + }); + + it('throws if reading code is missing', async () => { + prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' }); + prisma.referenceTable.findMany.mockResolvedValue([]); + await expect(service.validate({ modelId: 'm1', readings: [{ value: 10 } as any] })).rejects.toThrow('readings.code is required'); + }); + + it('flags missing required readings and out-of-range values', async () => { + prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' }); + prisma.referenceTable.findMany.mockResolvedValue([ + { + boilerModelId: 'model-1', + required: true, + minValue: new Prisma.Decimal(10), + maxValue: new Prisma.Decimal(20), + property: { code: 'pressure', label: 'Pressure', unit: 'bar' }, + }, + { + boilerModelId: 'model-1', + required: true, + minValue: new Prisma.Decimal(5), + maxValue: null, + property: { code: 'flow', label: 'Flow', unit: 'l/m' }, + } + ]); + + const result = await service.validate({ + modelId: 'model-1', + readings: [{ code: 'pressure', value: 25 }], + }); + + expect(result.valid).toBe(false); + expect(result.issues.find((i) => i.code === 'pressure')?.status).toBe('OUT_OF_RANGE'); + expect(result.issues.find((i) => i.code === 'flow')?.status).toBe('MISSING'); + }); + + it('rejects invalid reading payloads', async () => { + prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' }); + prisma.referenceTable.findMany.mockResolvedValue([]); + + await expect( + service.validate({ + modelId: 'model-1', + readings: [{ code: 'temp', value: Number.NaN }], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); - await expect( - service.validate({ + it('handles unknown readings', async () => { + prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' }); + prisma.referenceTable.findMany.mockResolvedValue([]); + + const result = await service.validate({ + modelId: 'model-1', + readings: [{ code: 'unknown', value: 10 }], + }); + expect(result.issues[0].status).toBe('UNKNOWN'); + }); + + it('validates correct readings successfully', async () => { + prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' }); + prisma.referenceTable.findMany.mockResolvedValue([ + { + boilerModelId: 'model-1', + required: true, + minValue: new Prisma.Decimal(10), + maxValue: new Prisma.Decimal(20), + property: { code: 'pressure', label: 'Pressure', unit: 'bar' }, + }, + ]); + + const result = await service.validate({ modelId: 'model-1', - readings: [{ code: 'temp', value: Number.NaN }], - }), - ).rejects.toBeInstanceOf(BadRequestException); + readings: [{ code: 'pressure', value: 15 }], + }); + expect(result.valid).toBe(true); + expect(result.issues[0].status).toBe('OK'); + }); }); }); diff --git a/apps/api/src/app/jobs/jobs.service.spec.ts b/apps/api/src/app/jobs/jobs.service.spec.ts index 0d1c221..77fec9b 100644 --- a/apps/api/src/app/jobs/jobs.service.spec.ts +++ b/apps/api/src/app/jobs/jobs.service.spec.ts @@ -5,6 +5,9 @@ import { JobsService } from './jobs.service'; const makePrisma = () => ({ job: { findMany: vi.fn(), + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), }, }); @@ -23,27 +26,146 @@ describe('JobsService', () => { service = new JobsService(prisma as any, scheduling as any); }); - it('findAll uses manager scope for managers', async () => { - prisma.job.findMany.mockResolvedValue([]); + describe('findAll', () => { + it('findAll uses manager scope for managers', async () => { + prisma.job.findMany.mockResolvedValue([]); + await service.findAll({ role: UserRole.MANAGER, sub: 'manager-1' } as any); + expect(prisma.job.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { isDeleted: false }, + }), + ); + }); - await service.findAll({ role: UserRole.MANAGER, sub: 'manager-1' } as any); + it('findAll scopes to technician for technicians', async () => { + prisma.job.findMany.mockResolvedValue([]); + await service.findAll({ role: UserRole.TECHNICIAN, sub: 'tech-1' } as any); + expect(prisma.job.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { technicianId: 'tech-1', isDeleted: false }, + }), + ); + }); + }); + + describe('create', () => { + it('throws if scheduledDate is invalid', async () => { + await expect(service.create({ scheduledDate: 'invalid' } as any)).rejects.toThrow('scheduledDate must be a valid date string'); + }); + + it('throws if estimatedDuration is invalid', async () => { + await expect(service.create({ scheduledDate: '2025-01-01', estimatedDuration: -5 } as any)).rejects.toThrow('estimatedDuration must be a positive integer'); + }); + + it('throws if technicianId is missing', async () => { + await expect(service.create({ scheduledDate: '2025-01-01', estimatedDuration: 120 } as any)).rejects.toThrow('technicianId is required'); + }); + + it('throws if siteId is missing', async () => { + await expect(service.create({ scheduledDate: '2025-01-01', estimatedDuration: 120, technicianId: 't1' } as any)).rejects.toThrow('siteId is required'); + }); + + it('throws if status is invalid', async () => { + await expect(service.create({ scheduledDate: '2025-01-01', estimatedDuration: 120, technicianId: 't1', siteId: 's1', status: 'INVALID' } as any)).rejects.toThrow('status is invalid'); + }); - expect(prisma.job.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { isDeleted: false }, - }), - ); + it('throws if there is a scheduling conflict', async () => { + scheduling.hasConflict.mockResolvedValue(true); + await expect(service.create({ scheduledDate: '2025-01-01', estimatedDuration: 120, technicianId: 't1', siteId: 's1' } as any)).rejects.toThrow('Job conflicts with existing schedule'); + }); + + it('creates job successfully', async () => { + scheduling.hasConflict.mockResolvedValue(false); + prisma.job.create.mockResolvedValue({ id: 'j1' } as any); + const res = await service.create({ scheduledDate: '2025-01-01', estimatedDuration: 120, technicianId: 't1', siteId: 's1' } as any); + expect(res.id).toBe('j1'); + expect(prisma.job.create).toHaveBeenCalled(); + }); }); - it('findAll scopes to technician for technicians', async () => { - prisma.job.findMany.mockResolvedValue([]); + describe('findOne', () => { + it('returns job if found for manager', async () => { + prisma.job.findFirst.mockResolvedValue({ id: 'j1' }); + const res = await service.findOne('j1', { role: UserRole.MANAGER, sub: 'm1' } as any); + expect(res.id).toBe('j1'); + }); + + it('throws if not found', async () => { + prisma.job.findFirst.mockResolvedValue(null); + await expect(service.findOne('j1', { role: UserRole.MANAGER, sub: 'm1' } as any)).rejects.toThrow('Job not found'); + }); + }); + + describe('update', () => { + it('throws if scheduledDate is invalid', async () => { + await expect(service.update('j1', { scheduledDate: 'invalid' } as any, {} as any)).rejects.toThrow('scheduledDate must be a valid date string'); + }); + + it('throws if estimatedDuration is invalid', async () => { + await expect(service.update('j1', { estimatedDuration: -5 } as any, {} as any)).rejects.toThrow('estimatedDuration must be a positive integer'); + }); + + it('throws if status is invalid', async () => { + await expect(service.update('j1', { status: 'INVALID' } as any, {} as any)).rejects.toThrow('status is invalid'); + }); + + it('updates job for manager', async () => { + prisma.job.update.mockResolvedValue({ id: 'j1' }); + await service.update('j1', { estimatedDuration: 120 } as any, { role: UserRole.MANAGER } as any); + expect(prisma.job.update).toHaveBeenCalled(); + }); + + it('throws NotFound if update fails for manager', async () => { + prisma.job.update.mockRejectedValue(new Error()); + await expect(service.update('j1', { estimatedDuration: 120 } as any, { role: UserRole.MANAGER } as any)).rejects.toThrow('Job not found'); + }); + + it('throws if technician tries to update job they cannot see', async () => { + prisma.job.findFirst.mockResolvedValue(null); + await expect(service.update('j1', { estimatedDuration: 120 } as any, { role: UserRole.TECHNICIAN, sub: 't1' } as any)).rejects.toThrow('Job not found'); + }); + + it('updates job for technician if they can see it', async () => { + prisma.job.findFirst.mockResolvedValue({ id: 'j1' }); + prisma.job.update.mockResolvedValue({ id: 'j1' }); + await service.update('j1', { estimatedDuration: 120 } as any, { role: UserRole.TECHNICIAN, sub: 't1' } as any); + expect(prisma.job.update).toHaveBeenCalled(); + }); + }); + + describe('updateStatus', () => { + it('throws if status is missing or invalid', async () => { + await expect(service.updateStatus('j1', {} as any, {} as any)).rejects.toThrow('status is invalid'); + }); + + it('throws if job not found', async () => { + prisma.job.findFirst.mockResolvedValue(null); + await expect(service.updateStatus('j1', { status: 'IN_PROGRESS' } as any, { role: UserRole.MANAGER } as any)).rejects.toThrow('Job not found'); + }); + + it('updates status', async () => { + prisma.job.findFirst.mockResolvedValue({ id: 'j1' }); + prisma.job.update.mockResolvedValue({ id: 'j1' }); + await service.updateStatus('j1', { status: 'IN_PROGRESS' } as any, { role: UserRole.MANAGER } as any); + expect(prisma.job.update).toHaveBeenCalled(); + }); + }); - await service.findAll({ role: UserRole.TECHNICIAN, sub: 'tech-1' } as any); + describe('remove', () => { + it('throws if job not found', async () => { + prisma.job.findFirst.mockResolvedValue(null); + await expect(service.remove('j1')).rejects.toThrow('Job not found'); + }); - expect(prisma.job.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { technicianId: 'tech-1', isDeleted: false }, - }), - ); + it('soft deletes job', async () => { + prisma.job.findFirst.mockResolvedValue({ id: 'j1' }); + prisma.job.update.mockResolvedValue({ id: 'j1' }); + await service.remove('j1'); + expect(prisma.job.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ isDeleted: true }), + }) + ); + }); }); }); diff --git a/apps/api/src/app/scheduling/scheduling.service.spec.ts b/apps/api/src/app/scheduling/scheduling.service.spec.ts index ac78232..53d7b9d 100644 --- a/apps/api/src/app/scheduling/scheduling.service.spec.ts +++ b/apps/api/src/app/scheduling/scheduling.service.spec.ts @@ -16,15 +16,98 @@ describe('SchedulingService', () => { service = new SchedulingService(prisma as any); }); - it('returns false when there are no conflicts', async () => { - prisma.job.findMany.mockResolvedValue([]); + describe('hasConflict', () => { + it('throws if technicianId is missing', async () => { + await expect(service.hasConflict('', new Date(), 60)).rejects.toThrow('technicianId is required'); + }); - const result = await service.hasConflict( - 'tech-1', - new Date('2026-01-01T09:00:00Z'), - 60, - ); + it('throws if scheduledDate is invalid', async () => { + await expect(service.hasConflict('t1', new Date('invalid'), 60)).rejects.toThrow('scheduledDate must be a valid Date'); + }); - expect(result).toBe(false); + it('throws if estimatedDuration is invalid', async () => { + await expect(service.hasConflict('t1', new Date(), -10)).rejects.toThrow('estimatedDuration must be a positive integer'); + }); + + it('returns false when there are no jobs', async () => { + prisma.job.findMany.mockResolvedValue([]); + const result = await service.hasConflict('tech-1', new Date('2026-01-01T09:00:00Z'), 60); + expect(result).toBe(false); + }); + + it('returns false when jobs do not overlap', async () => { + prisma.job.findMany.mockResolvedValue([ + { scheduledDate: new Date('2026-01-01T11:00:00Z'), estimatedDuration: 60 } + ]); + const result = await service.hasConflict('tech-1', new Date('2026-01-01T09:00:00Z'), 60); + expect(result).toBe(false); + }); + + it('returns true when jobs overlap', async () => { + prisma.job.findMany.mockResolvedValue([ + { scheduledDate: new Date('2026-01-01T09:30:00Z'), estimatedDuration: 60 } + ]); + const result = await service.hasConflict('tech-1', new Date('2026-01-01T09:00:00Z'), 60); + expect(result).toBe(true); + }); + + it('skips jobs without estimatedDuration', async () => { + prisma.job.findMany.mockResolvedValue([ + { scheduledDate: new Date('2026-01-01T09:30:00Z'), estimatedDuration: null } + ]); + const result = await service.hasConflict('tech-1', new Date('2026-01-01T09:00:00Z'), 60); + expect(result).toBe(false); + }); + }); + + describe('suggestAvailableTimeSlots', () => { + it('throws if technicianId is missing', async () => { + await expect(service.suggestAvailableTimeSlots({} as any)).rejects.toThrow('technicianId is required'); + }); + + it('throws if date is invalid', async () => { + await expect(service.suggestAvailableTimeSlots({ technicianId: 't1', date: 'invalid', estimatedDuration: 60 } as any)).rejects.toThrow('date must be in YYYY-MM-DD format'); + }); + + it('throws if estimatedDuration is invalid', async () => { + await expect(service.suggestAvailableTimeSlots({ technicianId: 't1', date: '2026-01-01', estimatedDuration: -5 } as any)).rejects.toThrow('estimatedDuration must be a positive integer'); + }); + + it('throws if travelBufferMinutes is invalid', async () => { + await expect(service.suggestAvailableTimeSlots({ technicianId: 't1', date: '2026-01-01', estimatedDuration: 60, travelBufferMinutes: -10 })).rejects.toThrow('travelBufferMinutes must be a non-negative integer'); + }); + + it('returns full work day slots if no jobs', async () => { + prisma.job.findMany.mockResolvedValue([]); + const slots = await service.suggestAvailableTimeSlots({ technicianId: 't1', date: '2026-01-01', estimatedDuration: 60 }); + expect(slots.length).toBe(1); + expect(slots[0].start).toEqual(new Date('2026-01-01T08:00:00Z')); + expect(slots[0].end).toEqual(new Date('2026-01-01T18:00:00Z')); + }); + + it('returns gaps around busy intervals', async () => { + prisma.job.findMany.mockResolvedValue([ + { scheduledDate: new Date('2026-01-01T10:00:00Z'), estimatedDuration: 120 } + ]); + const slots = await service.suggestAvailableTimeSlots({ technicianId: 't1', date: '2026-01-01', estimatedDuration: 60, travelBufferMinutes: 30 }); + expect(slots.length).toBe(2); + expect(slots[0].start).toEqual(new Date('2026-01-01T08:00:00Z')); + expect(slots[0].end).toEqual(new Date('2026-01-01T10:00:00Z')); + expect(slots[1].start).toEqual(new Date('2026-01-01T12:00:00Z')); + expect(slots[1].end).toEqual(new Date('2026-01-01T18:00:00Z')); + }); + + it('merges overlapping busy intervals', async () => { + prisma.job.findMany.mockResolvedValue([ + { scheduledDate: new Date('2026-01-01T10:00:00Z'), estimatedDuration: 60 }, + { scheduledDate: new Date('2026-01-01T10:30:00Z'), estimatedDuration: 60 } + ]); + const slots = await service.suggestAvailableTimeSlots({ technicianId: 't1', date: '2026-01-01', estimatedDuration: 60, travelBufferMinutes: 0 }); + expect(slots.length).toBe(2); + expect(slots[0].start).toEqual(new Date('2026-01-01T08:00:00Z')); + expect(slots[0].end).toEqual(new Date('2026-01-01T10:00:00Z')); + expect(slots[1].start).toEqual(new Date('2026-01-01T11:30:00Z')); + expect(slots[1].end).toEqual(new Date('2026-01-01T18:00:00Z')); + }); }); }); diff --git a/apps/api/src/app/service-logs/service-logs.service.spec.ts b/apps/api/src/app/service-logs/service-logs.service.spec.ts index 4af95c0..fc65186 100644 --- a/apps/api/src/app/service-logs/service-logs.service.spec.ts +++ b/apps/api/src/app/service-logs/service-logs.service.spec.ts @@ -140,63 +140,113 @@ describe('ServiceLogService', () => { expect(result.status).toBe('SUCCESS'); }); - it('update allows editing labor and parts after synced', async () => { - const now = new Date('2026-05-19T00:00:00.000Z'); - prisma.serviceLog.findFirst.mockResolvedValue({ - id: 'log-1', - jobId: 'job-1', - status: ServiceLogStatus.SYNCED, - summary: null, - notes: null, - syncedAt: now, - isDeleted: false, - deletedAt: null, - createdAt: now, - updatedAt: now, - job: { id: 'job-1' }, - laborEntries: [], - consumedParts: [], + describe('list', () => { + it('returns paginated items', async () => { + prisma.serviceLog.count.mockResolvedValue(1); + prisma.serviceLog.findMany.mockResolvedValue([ + { + id: 'log-1', + jobId: 'job-1', + status: ServiceLogStatus.DRAFT, + summary: null, + notes: null, + syncedAt: null, + isDeleted: false, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + laborEntries: [], + consumedParts: [], + } + ]); + const result = await service.list({ sub: 'm1', role: UserRole.MANAGER } as any, 'job-1', 1, 10); + expect(result.items.length).toBe(1); + expect(result.meta.total).toBe(1); }); - prisma.$transaction.mockImplementation(async (cb) => cb(prisma)); - prisma.part.findMany.mockResolvedValue([{ id: 'part-1' }]); - prisma.serviceLog.update.mockResolvedValue({ - id: 'log-1', - jobId: 'job-1', - status: ServiceLogStatus.SYNCED, - summary: null, - notes: null, - syncedAt: now, - isDeleted: false, - deletedAt: null, - createdAt: now, - updatedAt: now, - laborEntries: [], - consumedParts: [], + }); + + describe('getById', () => { + it('throws if not found', async () => { + prisma.serviceLog.findFirst.mockResolvedValue(null); + await expect(service.getById({ sub: 'm1', role: UserRole.MANAGER } as any, 'log-1')).rejects.toThrow('Service log not found'); }); - prisma.serviceLog.findUnique.mockResolvedValue({ - id: 'log-1', - jobId: 'job-1', - status: ServiceLogStatus.SYNCED, - summary: null, - notes: null, - syncedAt: now, - isDeleted: false, - deletedAt: null, - createdAt: now, - updatedAt: now, - laborEntries: [], - consumedParts: [], + + it('returns mapped log if found', async () => { + prisma.serviceLog.findFirst.mockResolvedValue({ + id: 'log-1', + jobId: 'job-1', + status: ServiceLogStatus.DRAFT, + summary: null, + notes: null, + syncedAt: null, + isDeleted: false, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + laborEntries: [], + consumedParts: [], + }); + const result = await service.getById({ sub: 'm1', role: UserRole.MANAGER } as any, 'log-1'); + expect(result.id).toBe('log-1'); }); + }); - await service.update( - { sub: 'tech-1', email: 'tech-1@a3.local', role: UserRole.TECHNICIAN }, - 'log-1', - { laborEntries: [{ hours: 1, hourlyRate: 50 }], consumedParts: [{ partId: 'part-1', quantity: 1 }] }, - ); + describe('create', () => { + it('throws if jobId is missing', async () => { + await expect(service.create({ sub: 't1', role: UserRole.TECHNICIAN } as any, {} as any)).rejects.toThrow('jobId is required'); + }); + + it('throws if job not found', async () => { + prisma.job.findFirst.mockResolvedValue(null); + await expect(service.create({ sub: 't1', role: UserRole.TECHNICIAN } as any, { jobId: 'job-1' } as any)).rejects.toThrow('Job not found'); + }); - expect(prisma.serviceLog.update).toHaveBeenCalled(); - expect(prisma.laborEntry.deleteMany).toHaveBeenCalledWith({ where: { serviceLogId: 'log-1' } }); - expect(prisma.laborEntry.createMany).toHaveBeenCalled(); - expect(prisma.consumedPart.deleteMany).toHaveBeenCalledWith({ where: { serviceLogId: 'log-1' } }); + it('throws if labor hours invalid', async () => { + prisma.job.findFirst.mockResolvedValue({ id: 'job-1' }); + await expect(service.create({ sub: 't1', role: UserRole.TECHNICIAN } as any, { jobId: 'job-1', laborEntries: [{ hours: -1, hourlyRate: 100 }] } as any)).rejects.toThrow('laborEntries[0].hours must be positive'); + }); + + it('throws if partId missing', async () => { + prisma.job.findFirst.mockResolvedValue({ id: 'job-1' }); + await expect(service.create({ sub: 't1', role: UserRole.TECHNICIAN } as any, { jobId: 'job-1', consumedParts: [{ quantity: 1 }] } as any)).rejects.toThrow('consumedParts[0].partId is required'); + }); + }); + + describe('update', () => { + it('throws if not found', async () => { + prisma.serviceLog.findFirst.mockResolvedValue(null); + await expect(service.update({ sub: 'm1', role: UserRole.MANAGER } as any, 'log-1', {} as any)).rejects.toThrow('Service log not found'); + }); + + it('throws if updated record returns null', async () => { + prisma.serviceLog.findFirst.mockResolvedValue({ id: 'log-1' }); + prisma.serviceLog.update.mockResolvedValue(null); + prisma.serviceLog.findUnique.mockResolvedValue(null); + await expect(service.update({ sub: 'm1', role: UserRole.MANAGER } as any, 'log-1', {} as any)).rejects.toThrow('Service log not found'); + }); + }); + + describe('sync', () => { + it('throws if idempotencyKey missing', async () => { + await expect(service.sync({ sub: 'm1', role: UserRole.MANAGER } as any, 'log-1', {} as any)).rejects.toThrow('idempotencyKey is required'); + }); + + it('throws if jobId missing', async () => { + await expect(service.sync({ sub: 'm1', role: UserRole.MANAGER } as any, 'log-1', { idempotencyKey: 'k' } as any)).rejects.toThrow('jobId is required'); + }); + + it('throws if already synced and trying to change labor/parts', async () => { + prisma.serviceLogSync.findUnique.mockResolvedValue(null); + prisma.job.findFirst.mockResolvedValue({ id: 'job-1' }); + + const tx = { + serviceLog: { + findUnique: vi.fn().mockResolvedValue({ id: 'log-1', status: ServiceLogStatus.SYNCED }), + } + }; + prisma.$transaction.mockImplementation(async (cb: any) => cb(tx)); + + await expect(service.sync({ sub: 'm1', role: UserRole.MANAGER } as any, 'log-1', { idempotencyKey: 'k', jobId: 'job-1', laborEntries: [] } as any)).rejects.toThrow('Synced logs cannot change labor or parts'); + }); }); }); diff --git a/apps/api/src/app/sync/sync-conflicts.service.spec.ts b/apps/api/src/app/sync/sync-conflicts.service.spec.ts index 869b3ed..8d501db 100644 --- a/apps/api/src/app/sync/sync-conflicts.service.spec.ts +++ b/apps/api/src/app/sync/sync-conflicts.service.spec.ts @@ -27,68 +27,127 @@ describe('SyncConflictsService', () => { service = new SyncConflictsService(prisma as unknown as PrismaService); }); - it('resolves an open conflict and records a sync log', async () => { - prisma.syncConflict.findUnique.mockResolvedValue({ - id: 'conflict-1', - affectedEntity: 'ServiceLog', - affectedId: 'log-1', - status: ConflictStatus.OPEN, - policy: null, - details: null, - resolutionNotes: null, - resolvedAt: null, - createdAt: new Date('2026-05-24T00:00:00.000Z'), - updatedAt: new Date('2026-05-24T00:00:00.000Z'), + describe('list', () => { + it('returns paginated list of conflicts', async () => { + prisma.syncConflict.count.mockResolvedValue(1); + prisma.syncConflict.findMany.mockResolvedValue([ + { + id: 'conflict-1', + affectedEntity: 'ServiceLog', + affectedId: 'log-1', + status: ConflictStatus.OPEN, + policy: null, + details: null, + resolutionNotes: null, + resolvedAt: null, + createdAt: new Date('2026-05-24T00:00:00.000Z'), + updatedAt: new Date('2026-05-24T00:00:00.000Z'), + } + ]); + + const result = await service.list(ConflictStatus.OPEN, 1, 10); + expect(prisma.syncConflict.count).toHaveBeenCalledWith({ where: { status: ConflictStatus.OPEN } }); + expect(prisma.syncConflict.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { status: ConflictStatus.OPEN }, + skip: 0, + take: 10, + }) + ); + expect(result.items.length).toBe(1); + expect(result.meta.total).toBe(1); }); - prisma.syncConflict.update.mockResolvedValue({ - id: 'conflict-1', - affectedEntity: 'ServiceLog', - affectedId: 'log-1', - status: ConflictStatus.RESOLVED, - policy: 'SERVER_WINS', - details: null, - resolutionNotes: 'use server', - resolvedAt: new Date('2026-05-24T00:00:00.000Z'), - createdAt: new Date('2026-05-24T00:00:00.000Z'), - updatedAt: new Date('2026-05-24T00:00:00.000Z'), + it('uses default pagination values', async () => { + prisma.syncConflict.count.mockResolvedValue(0); + prisma.syncConflict.findMany.mockResolvedValue([]); + + const result = await service.list(undefined); + expect(prisma.syncConflict.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: {}, + skip: 0, + take: 25, + }) + ); + expect(result.meta.page).toBe(1); + expect(result.meta.pageSize).toBe(25); }); + }); - prisma.syncLog.create.mockResolvedValue({ - id: 'log-1', - action: SyncAction.CONFLICT, - result: SyncResult.SUCCESS, + describe('resolve', () => { + it('throws if conflictId is missing', async () => { + await expect(service.resolve({} as any)).rejects.toThrow('conflictId is required'); }); - const result = await service.resolve({ - conflictId: 'conflict-1', - policy: 'SERVER_WINS', - resolutionNotes: 'use server', + it('throws if conflict is not found', async () => { + prisma.syncConflict.findUnique.mockResolvedValue(null); + await expect(service.resolve({ conflictId: 'invalid', policy: 'SERVER_WINS' } as any)).rejects.toThrow('Conflict not found'); }); - expect(result.logId).toBe('log-1'); - expect(result.conflict.status).toBe(ConflictStatus.RESOLVED); - }); + it('resolves an open conflict and records a sync log', async () => { + prisma.syncConflict.findUnique.mockResolvedValue({ + id: 'conflict-1', + affectedEntity: 'ServiceLog', + affectedId: 'log-1', + status: ConflictStatus.OPEN, + policy: null, + details: null, + resolutionNotes: null, + resolvedAt: null, + createdAt: new Date('2026-05-24T00:00:00.000Z'), + updatedAt: new Date('2026-05-24T00:00:00.000Z'), + }); + + prisma.syncConflict.update.mockResolvedValue({ + id: 'conflict-1', + affectedEntity: 'ServiceLog', + affectedId: 'log-1', + status: ConflictStatus.RESOLVED, + policy: 'SERVER_WINS', + details: null, + resolutionNotes: 'use server', + resolvedAt: new Date('2026-05-24T00:00:00.000Z'), + createdAt: new Date('2026-05-24T00:00:00.000Z'), + updatedAt: new Date('2026-05-24T00:00:00.000Z'), + }); - it('rejects resolving an already resolved conflict', async () => { - prisma.syncConflict.findUnique.mockResolvedValue({ - id: 'conflict-2', - affectedEntity: 'ServiceLog', - affectedId: 'log-2', - status: ConflictStatus.RESOLVED, - policy: 'CLIENT_WINS', - details: null, - resolutionNotes: null, - resolvedAt: new Date('2026-05-24T00:00:00.000Z'), - createdAt: new Date('2026-05-24T00:00:00.000Z'), - updatedAt: new Date('2026-05-24T00:00:00.000Z'), + prisma.syncLog.create.mockResolvedValue({ + id: 'log-1', + action: SyncAction.CONFLICT, + result: SyncResult.SUCCESS, + }); + + const result = await service.resolve({ + conflictId: 'conflict-1', + policy: 'SERVER_WINS', + resolutionNotes: 'use server', + } as any); + + expect(result.logId).toBe('log-1'); + expect(result.conflict.status).toBe(ConflictStatus.RESOLVED); }); - await expect( - service.resolve({ - conflictId: 'conflict-2', + it('rejects resolving an already resolved conflict', async () => { + prisma.syncConflict.findUnique.mockResolvedValue({ + id: 'conflict-2', + affectedEntity: 'ServiceLog', + affectedId: 'log-2', + status: ConflictStatus.RESOLVED, policy: 'CLIENT_WINS', - }), - ).rejects.toBeInstanceOf(BadRequestException); + details: null, + resolutionNotes: null, + resolvedAt: new Date('2026-05-24T00:00:00.000Z'), + createdAt: new Date('2026-05-24T00:00:00.000Z'), + updatedAt: new Date('2026-05-24T00:00:00.000Z'), + }); + + await expect( + service.resolve({ + conflictId: 'conflict-2', + policy: 'CLIENT_WINS', + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + }); }); }); diff --git a/apps/api/src/app/warranty/warranty.service.spec.ts b/apps/api/src/app/warranty/warranty.service.spec.ts index b16c31a..1eceb08 100644 --- a/apps/api/src/app/warranty/warranty.service.spec.ts +++ b/apps/api/src/app/warranty/warranty.service.spec.ts @@ -37,46 +37,237 @@ describe('WarrantyService', () => { ); }); - it('rejects warranty creation when commissioning fails', async () => { - prisma.job.findFirst.mockResolvedValue({ id: 'job-1' }); - prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' }); - commissioningService.validate.mockResolvedValue({ valid: false }); + describe('list', () => { + it('returns paginated items for technician', async () => { + prisma.warranty.count.mockResolvedValue(1); + prisma.warranty.findMany.mockResolvedValue([ + { + id: 'w-1', + boilerModelId: 'model-1', + jobId: 'job-1', + startDate: new Date(), + durationMonths: 12, + expiresAt: new Date(), + status: WarrantyStatus.ACTIVE, + notes: null, + isDeleted: false, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + } + ]); + + const result = await service.list({ sub: 'tech-1', role: UserRole.TECHNICIAN } as any, WarrantyStatus.ACTIVE, 1, 10); + expect(result.items.length).toBe(1); + expect(result.meta.total).toBe(1); + }); + }); + + describe('getById', () => { + it('throws if not found', async () => { + prisma.warranty.findFirst.mockResolvedValue(null); + await expect(service.getById({ sub: 'm1', role: UserRole.MANAGER } as any, 'w-1')).rejects.toThrow('Warranty not found'); + }); + + it('returns warranty if found', async () => { + prisma.warranty.findFirst.mockResolvedValue({ + id: 'w-1', + boilerModelId: 'model-1', + jobId: 'job-1', + startDate: new Date(), + durationMonths: 12, + expiresAt: new Date(), + status: WarrantyStatus.ACTIVE, + notes: null, + isDeleted: false, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + const result = await service.getById({ sub: 'm1', role: UserRole.MANAGER } as any, 'w-1'); + expect(result.id).toBe('w-1'); + }); + }); + + describe('create', () => { + it('throws if jobId is missing', async () => { + await expect(service.create({} as any, {} as any)).rejects.toThrow('jobId is required'); + }); + + it('throws if boilerModelId is missing', async () => { + await expect(service.create({} as any, { jobId: 'j' } as any)).rejects.toThrow('boilerModelId is required'); + }); + + it('throws if readings missing', async () => { + await expect(service.create({} as any, { jobId: 'j', boilerModelId: 'm' } as any)).rejects.toThrow('readings are required'); + }); + + it('throws if startDate is invalid', async () => { + await expect(service.create({} as any, { jobId: 'j', boilerModelId: 'm', readings: [{}], startDate: 'invalid' } as any)).rejects.toThrow('startDate must be a valid ISO date string'); + }); + + it('throws if durationMonths is invalid', async () => { + await expect(service.create({} as any, { jobId: 'j', boilerModelId: 'm', readings: [{}], durationMonths: -5 } as any)).rejects.toThrow('durationMonths must be a positive integer'); + }); - await expect( - service.create( + it('throws if job not found', async () => { + prisma.job.findFirst.mockResolvedValue(null); + await expect(service.create({ sub: 'm', role: UserRole.MANAGER } as any, { jobId: 'j', boilerModelId: 'm', readings: [{}] } as any)).rejects.toThrow('Job not found'); + }); + + it('throws if boiler model not found', async () => { + prisma.job.findFirst.mockResolvedValue({ id: 'j' }); + prisma.boilerModel.findFirst.mockResolvedValue(null); + await expect(service.create({ sub: 'm', role: UserRole.MANAGER } as any, { jobId: 'j', boilerModelId: 'm', readings: [{}] } as any)).rejects.toThrow('Boiler model not found'); + }); + + it('rejects warranty creation when commissioning fails', async () => { + prisma.job.findFirst.mockResolvedValue({ id: 'job-1' }); + prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' }); + commissioningService.validate.mockResolvedValue({ valid: false }); + + await expect( + service.create( + { sub: 'tech-1', email: 'tech-1@a3.local', role: UserRole.TECHNICIAN }, + { + jobId: 'job-1', + boilerModelId: 'model-1', + readings: [{ code: 'pressure', value: 10 }], + }, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('creates warranty if validation passes', async () => { + prisma.job.findFirst.mockResolvedValue({ id: 'job-1' }); + prisma.boilerModel.findFirst.mockResolvedValue({ id: 'model-1' }); + commissioningService.validate.mockResolvedValue({ valid: true }); + prisma.warranty.create.mockResolvedValue({ + id: 'w-1', + boilerModelId: 'model-1', + jobId: 'job-1', + startDate: new Date(), + durationMonths: 12, + expiresAt: new Date(), + status: WarrantyStatus.ACTIVE, + notes: null, + isDeleted: false, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.create( { sub: 'tech-1', email: 'tech-1@a3.local', role: UserRole.TECHNICIAN }, { jobId: 'job-1', boilerModelId: 'model-1', readings: [{ code: 'pressure', value: 10 }], }, - ), - ).rejects.toBeInstanceOf(BadRequestException); + ); + expect(result.id).toBe('w-1'); + }); }); - it('prevents activation when warranty is already expired', async () => { - const expired = new Date('2024-01-01T00:00:00.000Z'); - prisma.warranty.findFirst.mockResolvedValue({ - id: 'w-1', - boilerModelId: 'model-1', - jobId: 'job-1', - startDate: new Date('2023-01-01T00:00:00.000Z'), - durationMonths: 12, - expiresAt: expired, - status: WarrantyStatus.EXPIRED, - notes: null, - isDeleted: false, - deletedAt: null, - createdAt: expired, - updatedAt: expired, - }); - - await expect( - service.updateStatus( + describe('updateStatus', () => { + it('throws if status is missing', async () => { + await expect(service.updateStatus({} as any, 'w-1', {} as any)).rejects.toThrow('status is required'); + }); + + it('throws if warranty not found', async () => { + prisma.warranty.findFirst.mockResolvedValue(null); + await expect(service.updateStatus({} as any, 'w-1', { status: WarrantyStatus.ACTIVE } as any)).rejects.toThrow('Warranty not found'); + }); + + it('prevents activation when warranty is already expired', async () => { + const expired = new Date('2024-01-01T00:00:00.000Z'); + prisma.warranty.findFirst.mockResolvedValue({ + id: 'w-1', + boilerModelId: 'model-1', + jobId: 'job-1', + startDate: new Date('2023-01-01T00:00:00.000Z'), + durationMonths: 12, + expiresAt: expired, + status: WarrantyStatus.EXPIRED, + notes: null, + isDeleted: false, + deletedAt: null, + createdAt: expired, + updatedAt: expired, + }); + + await expect( + service.updateStatus( + { sub: 'tech-1', email: 'tech-1@a3.local', role: UserRole.TECHNICIAN }, + 'w-1', + { status: WarrantyStatus.ACTIVE }, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('prevents expiry before expiry date', async () => { + const future = new Date(Date.now() + 100000000); + prisma.warranty.findFirst.mockResolvedValue({ + id: 'w-1', + boilerModelId: 'model-1', + jobId: 'job-1', + startDate: new Date(), + durationMonths: 12, + expiresAt: future, + status: WarrantyStatus.ACTIVE, + notes: null, + isDeleted: false, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await expect( + service.updateStatus( + { sub: 'tech-1', email: 'tech-1@a3.local', role: UserRole.TECHNICIAN }, + 'w-1', + { status: WarrantyStatus.EXPIRED }, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('updates status successfully', async () => { + const future = new Date(Date.now() + 100000000); + prisma.warranty.findFirst.mockResolvedValue({ + id: 'w-1', + boilerModelId: 'model-1', + jobId: 'job-1', + startDate: new Date(), + durationMonths: 12, + expiresAt: future, + status: WarrantyStatus.ACTIVE, + notes: null, + isDeleted: false, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + prisma.warranty.update.mockResolvedValue({ + id: 'w-1', + boilerModelId: 'model-1', + jobId: 'job-1', + startDate: new Date(), + durationMonths: 12, + expiresAt: future, + status: WarrantyStatus.VOID, + notes: null, + isDeleted: false, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.updateStatus( { sub: 'tech-1', email: 'tech-1@a3.local', role: UserRole.TECHNICIAN }, 'w-1', - { status: WarrantyStatus.ACTIVE }, - ), - ).rejects.toBeInstanceOf(BadRequestException); + { status: WarrantyStatus.VOID }, + ); + expect(result.status).toBe(WarrantyStatus.VOID); + }); }); }); diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index 0f2334a..4125316 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -37,46 +37,135 @@ describe('AuthService', () => { (bcrypt.compare as any).mockReset(); }); - it('login issues access and refresh tokens', async () => { - (bcrypt.compare as any).mockResolvedValue(true); - prisma.user.findUnique.mockResolvedValue({ - id: 'user-1', - email: 'test@example.com', - passwordHash: 'hash', - role: UserRole.MANAGER, + describe('login', () => { + it('login issues access and refresh tokens', async () => { + (bcrypt.compare as any).mockResolvedValue(true); + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'test@example.com', + passwordHash: 'hash', + role: UserRole.MANAGER, + }); + jwt.signAsync.mockResolvedValue('access-token'); + + const result = await service.login('test@example.com', 'pw'); + + expect(result.access_token).toBe('access-token'); + expect(result.refresh_token).toBeTypeOf('string'); + expect(prisma.refreshSession.create).toHaveBeenCalledTimes(1); }); - jwt.signAsync.mockResolvedValue('access-token'); - const result = await service.login('test@example.com', 'pw'); + it('rejects if user not found', async () => { + prisma.user.findUnique.mockResolvedValue(null); + await expect(service.login('test@example.com', 'pw')).rejects.toBeInstanceOf(UnauthorizedException); + }); - expect(result.access_token).toBe('access-token'); - expect(result.refresh_token).toBeTypeOf('string'); - expect(prisma.refreshSession.create).toHaveBeenCalledTimes(1); + it('rejects if password wrong', async () => { + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + passwordHash: 'hash', + }); + (bcrypt.compare as any).mockResolvedValue(false); + await expect(service.login('test@example.com', 'pw')).rejects.toBeInstanceOf(UnauthorizedException); + }); }); - it('refresh rejects invalid refresh token', async () => { - const tx = { - refreshSession: { - findUnique: vi.fn().mockResolvedValue(null), - }, - }; - prisma.$transaction.mockImplementation(async (cb: any) => cb(tx)); - - await expect(service.refresh('bad-token')).rejects.toBeInstanceOf( - UnauthorizedException, - ); + describe('refresh', () => { + it('refresh rejects invalid refresh token', async () => { + const tx = { + refreshSession: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + prisma.$transaction.mockImplementation(async (cb: any) => cb(tx)); + + await expect(service.refresh('bad-token')).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('refresh rejects revoked refresh token', async () => { + const tx = { + refreshSession: { + findUnique: vi.fn().mockResolvedValue({ revokedAt: new Date() }), + }, + }; + prisma.$transaction.mockImplementation(async (cb: any) => cb(tx)); + + await expect(service.refresh('revoked-token')).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('refresh rejects expired refresh token', async () => { + const tx = { + refreshSession: { + findUnique: vi.fn().mockResolvedValue({ expiresAt: new Date(Date.now() - 1000) }), + }, + }; + prisma.$transaction.mockImplementation(async (cb: any) => cb(tx)); + + await expect(service.refresh('expired-token')).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('refresh issues new tokens', async () => { + const tx = { + refreshSession: { + findUnique: vi.fn().mockResolvedValue({ + id: 'sess-1', + expiresAt: new Date(Date.now() + 60000), + userId: 'user-1', + user: { id: 'user-1', email: 'test@example.com', role: UserRole.MANAGER } + }), + update: vi.fn(), + create: vi.fn(), + }, + }; + prisma.$transaction.mockImplementation(async (cb: any) => cb(tx)); + jwt.signAsync.mockResolvedValue('new-access-token'); + + const result = await service.refresh('good-token'); + expect(result.access_token).toBe('new-access-token'); + expect(result.refresh_token).toBeTypeOf('string'); + expect(tx.refreshSession.update).toHaveBeenCalled(); + expect(tx.refreshSession.create).toHaveBeenCalled(); + }); }); - it('logout revokes a refresh token', async () => { - prisma.refreshSession.findUnique.mockResolvedValue({ - id: 'sess-1', - expiresAt: new Date(Date.now() + 60_000), - revokedAt: null, + describe('logout', () => { + it('logout revokes a refresh token', async () => { + prisma.refreshSession.findUnique.mockResolvedValue({ + id: 'sess-1', + expiresAt: new Date(Date.now() + 60_000), + revokedAt: null, + }); + + const result = await service.logout('token'); + + expect(result.success).toBe(true); + expect(prisma.refreshSession.update).toHaveBeenCalledTimes(1); }); - const result = await service.logout('token'); + it('logout rejects invalid refresh token', async () => { + prisma.refreshSession.findUnique.mockResolvedValue(null); + await expect(service.logout('token')).rejects.toBeInstanceOf(UnauthorizedException); + }); - expect(result.success).toBe(true); - expect(prisma.refreshSession.update).toHaveBeenCalledTimes(1); + it('logout rejects expired refresh token', async () => { + prisma.refreshSession.findUnique.mockResolvedValue({ + id: 'sess-1', + expiresAt: new Date(Date.now() - 60_000), + }); + await expect(service.logout('token')).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('logout succeeds if already revoked', async () => { + prisma.refreshSession.findUnique.mockResolvedValue({ + id: 'sess-1', + expiresAt: new Date(Date.now() + 60_000), + revokedAt: new Date(), + }); + + const result = await service.logout('token'); + + expect(result.success).toBe(true); + expect(prisma.refreshSession.update).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/api/src/rbac/roles.guard.spec.ts b/apps/api/src/rbac/roles.guard.spec.ts new file mode 100644 index 0000000..85bf433 --- /dev/null +++ b/apps/api/src/rbac/roles.guard.spec.ts @@ -0,0 +1,50 @@ +import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { UserRole } from '../generated/prisma/client'; +import { RolesGuard } from './roles.guard'; + +describe('RolesGuard', () => { + let reflector: Reflector; + let guard: RolesGuard; + let mockContext: Partial; + + beforeEach(() => { + reflector = new Reflector(); + guard = new RolesGuard(reflector); + + mockContext = { + getHandler: vi.fn(), + getClass: vi.fn(), + switchToHttp: vi.fn().mockReturnValue({ + getRequest: vi.fn().mockReturnValue({}), + }), + }; + }); + + it('returns true if no roles required', () => { + vi.spyOn(reflector, 'getAllAndOverride').mockReturnValue(null as any); + expect(guard.canActivate(mockContext as ExecutionContext)).toBe(true); + }); + + it('throws UnauthorizedException if user missing', () => { + vi.spyOn(reflector, 'getAllAndOverride').mockReturnValue([UserRole.MANAGER]); + expect(() => guard.canActivate(mockContext as ExecutionContext)).toThrow(UnauthorizedException); + }); + + it('throws ForbiddenException if role insufficient', () => { + vi.spyOn(reflector, 'getAllAndOverride').mockReturnValue([UserRole.MANAGER]); + (mockContext.switchToHttp as any)().getRequest.mockReturnValue({ + user: { role: UserRole.TECHNICIAN }, + }); + expect(() => guard.canActivate(mockContext as ExecutionContext)).toThrow(ForbiddenException); + }); + + it('returns true if role is sufficient', () => { + vi.spyOn(reflector, 'getAllAndOverride').mockReturnValue([UserRole.MANAGER]); + (mockContext.switchToHttp as any)().getRequest.mockReturnValue({ + user: { role: UserRole.MANAGER }, + }); + expect(guard.canActivate(mockContext as ExecutionContext)).toBe(true); + }); +}); From 4db6a8c1bed23bd3a316bb5857219efd908361d8 Mon Sep 17 00:00:00 2001 From: alde23 Date: Mon, 1 Jun 2026 17:04:18 +0200 Subject: [PATCH 8/8] ci(pipeline): add --forceExit to fix hanging mobile tests --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0eed5c8..5cd19ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: run: npx nx build shared-schema && npx nx build api - name: Run Tests - run: npx nx run-many --target=test --all --coverage + run: npx nx run-many --target=test --all --coverage --forceExit env: DATABASE_URL: postgresql://postgres:postgres@localhost:5433/a3service DIRECT_URL: postgresql://postgres:postgres@localhost:5433/a3service \ No newline at end of file