import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; // ── DB setup (real in-memory SQLite — same pattern as mcp unit tests) ──────── const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA foreign_keys = ON'); db.exec('PRAGMA busy_timeout = 5000'); const mock = { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, canAccessTrip: (tripId: any, userId: number) => db.prepare(` SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) `).get(userId, tripId, userId), isOwner: (tripId: any, userId: number) => !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), }; return { testDb: db, dbMock: mock }; }); vi.mock('../../../src/db/database', () => dbMock); import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip } from '../../helpers/factories'; import { getStats, getCached, setCache, getCountryFromCoords, getCountryFromAddress, reverseGeocodeCountry, getRegionGeo, getCountryPlaces, getVisitedRegions } from '../../../src/services/atlasService'; function insertPlace(db: any, tripId: number, name: string, address: string | null = null) { const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined; const result = db.prepare( 'INSERT INTO places (trip_id, name, address, category_id) VALUES (?, ?, ?, ?)' ).run(tripId, name, address, cat?.id ?? null); return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid); } beforeAll(() => { createTables(testDb); runMigrations(testDb); }); beforeEach(() => { resetTestDb(testDb); // Stub fetch so reverseGeocodeCountry never makes real HTTP calls vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}), })); }); afterAll(() => { vi.unstubAllGlobals(); testDb.close(); }); // ── Tests ──────────────────────────────────────────────────────────────────── describe('getStats', () => { it('ATLAS-UNIT-001: returns mostVisited null when trips have no resolvable countries (guards reduce on empty array)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Mystery Trip' }); // Place with no address and no coordinates → can't resolve country insertPlace(testDb, trip.id, 'Unknown Place', null); const stats = await getStats(user.id); expect(stats.mostVisited).toBeNull(); expect(stats.countries).toEqual([]); expect(stats.stats.totalPlaces).toBe(1); expect(stats.stats.totalCountries).toBe(0); }); it('ATLAS-UNIT-002: returns the country with the highest placeCount as mostVisited', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Euro Tour' }); // 3 places in France, 1 in Germany → France should win for (let i = 0; i < 3; i++) { insertPlace(testDb, trip.id, `Paris Place ${i}`, `Street ${i}, Paris, France`); } insertPlace(testDb, trip.id, 'Berlin Place', 'Some Street, Berlin, Germany'); const stats = await getStats(user.id); expect(stats.mostVisited).not.toBeNull(); expect(stats.mostVisited!.code).toBe('FR'); expect(stats.mostVisited!.placeCount).toBe(3); expect(stats.countries).toHaveLength(2); expect(stats.stats.totalCountries).toBe(2); }); it('ATLAS-UNIT-003: returns manually marked countries when user has no trips', async () => { const { user } = createUser(testDb); testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP'); testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'AU'); const stats = await getStats(user.id); expect(stats.countries).toHaveLength(2); expect(stats.countries.map((c: { code: string }) => c.code).sort()).toEqual(['AU', 'JP']); expect(stats.stats.totalTrips).toBe(0); expect(stats.stats.totalCountries).toBe(2); }); it('ATLAS-UNIT-004: single country yields mostVisited equal to that country', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Italy Trip' }); insertPlace(testDb, trip.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy'); const stats = await getStats(user.id); expect(stats.mostVisited).not.toBeNull(); expect(stats.mostVisited!.code).toBe('IT'); expect(stats.mostVisited!.placeCount).toBe(1); }); }); // ── getCached / setCache ──────────────────────────────────────────────────── describe('getCached and setCache', () => { it('ATLAS-SVC-001: getCached returns undefined for unknown coordinates', () => { // Use uniquely large lat values to guarantee no prior cache entry const result = getCached(9001.001, 9001.001); expect(result).toBeUndefined(); }); it('ATLAS-SVC-002: setCache then getCached returns the stored code', () => { setCache(9002.002, 9002.002, 'DE'); const result = getCached(9002.002, 9002.002); expect(result).toBe('DE'); }); it('ATLAS-SVC-003: setCache can store null (country unknown)', () => { setCache(9003.003, 9003.003, null); const result = getCached(9003.003, 9003.003); expect(result).toBeNull(); }); it('ATLAS-SVC-004: different coordinates return different cached values', () => { setCache(9004.004, 9004.004, 'FR'); setCache(9004.005, 9004.005, 'ES'); expect(getCached(9004.004, 9004.004)).toBe('FR'); expect(getCached(9004.005, 9004.005)).toBe('ES'); }); }); // ── getCountryFromCoords ──────────────────────────────────────────────────── describe('getCountryFromCoords', () => { it('ATLAS-SVC-005: returns country code for Paris coordinates (France)', () => { // Paris: approximately 48.85°N, 2.35°E — well inside FR bounding box const code = getCountryFromCoords(48.85, 2.35); expect(code).toBe('FR'); }); it('ATLAS-SVC-006: returns country code for NYC coordinates (USA)', () => { // New York City: approximately 40.71°N, -74.0°W — inside US bounding box const code = getCountryFromCoords(40.71, -74.0); expect(code).toBe('US'); }); it('ATLAS-SVC-007: returns null for coordinates with no country match (0,0)', () => { // Gulf of Guinea — no COUNTRY_BOXES entry covers 0°N, 0°E const code = getCountryFromCoords(0.0, 0.0); expect(code).toBeNull(); }); }); // ── getCountryFromAddress ─────────────────────────────────────────────────── describe('getCountryFromAddress', () => { it('ATLAS-SVC-008: returns null for null address', () => { expect(getCountryFromAddress(null)).toBeNull(); }); it('ATLAS-SVC-009: returns null for empty string', () => { expect(getCountryFromAddress('')).toBeNull(); }); it('ATLAS-SVC-010: parses "France" in last position to "FR"', () => { expect(getCountryFromAddress('Eiffel Tower, Paris, France')).toBe('FR'); }); it('ATLAS-SVC-011: returns 2-letter ISO code directly when last part is uppercase 2-letter', () => { // "US" is uppercase and exactly 2 characters — returned verbatim expect(getCountryFromAddress('123 Main St, New York, US')).toBe('US'); }); it('ATLAS-SVC-012: returns null for unrecognized country name', () => { expect(getCountryFromAddress('Unknown City, Unknown Country')).toBeNull(); }); }); // ── reverseGeocodeCountry ─────────────────────────────────────────────────── describe('reverseGeocodeCountry', () => { it('ATLAS-SVC-013: returns null when fetch fails (ok:false)', async () => { // The beforeEach stub already returns ok:false — this is the default path const code = await reverseGeocodeCountry(9013.013, 9013.013); expect(code).toBeNull(); }); it('ATLAS-SVC-014: returns country code when Nominatim returns valid response', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ address: { country_code: 'fr' } }), })); // Berlin-ish coords not used elsewhere — unique to avoid cache collision const code = await reverseGeocodeCountry(52.52, 13.40); expect(code).toBe('FR'); }); it('ATLAS-SVC-015: returns null when fetch throws a network error', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); const code = await reverseGeocodeCountry(9015.015, 9015.015); expect(code).toBeNull(); }); it('ATLAS-SVC-016: returns cached result on second call (fetch called only once)', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ address: { country_code: 'gb' } }), }); vi.stubGlobal('fetch', mockFetch); // Use unique coords so neither call hits a prior cache entry const first = await reverseGeocodeCountry(9016.016, 9016.016); const second = await reverseGeocodeCountry(9016.016, 9016.016); expect(first).toBe('GB'); expect(second).toBe('GB'); // fetch should have been invoked only once; the second call uses the in-memory cache expect(mockFetch).toHaveBeenCalledTimes(1); }); }); // ── getRegionGeo ──────────────────────────────────────────────────────────── describe('getRegionGeo', () => { it('ATLAS-SVC-017: returns empty FeatureCollection when fetch throws a network error', async () => { // Override the default stub to throw so loadAdmin1Geo's .catch handler runs, // returning null — which causes getRegionGeo to return the empty FeatureCollection. // (The default ok:false stub does NOT trigger the catch; it still resolves json() // to {}, which loadAdmin1Geo caches as a non-null truthy value.) vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure'))); const result = await getRegionGeo(['DE', 'FR']); expect(result).toEqual({ type: 'FeatureCollection', features: [] }); }); it('ATLAS-SVC-018: returns filtered features for matching country codes when fetch returns mock GeoJSON', async () => { // ATLAS-SVC-017 ran with a throwing fetch, so admin1GeoCache is null and // admin1GeoLoading is null — this test's fetch override will be called. const mockGeoJson = { type: 'FeatureCollection', features: [ { type: 'Feature', properties: { iso_a2: 'DE' }, geometry: {} }, { type: 'Feature', properties: { iso_a2: 'FR' }, geometry: {} }, ], }; vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => mockGeoJson, })); // Pass lowercase 'de' — getRegionGeo uppercases internally for matching const result = await getRegionGeo(['de']); expect(result.type).toBe('FeatureCollection'); expect(result.features).toHaveLength(1); expect(result.features[0].properties.iso_a2).toBe('DE'); }); }); // ── Helpers for new tests ──────────────────────────────────────────────────── function insertPlaceWithCoords(db: any, tripId: number, name: string, lat: number, lng: number, address: string | null = null) { const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined; const result = db.prepare( 'INSERT INTO places (trip_id, name, address, lat, lng, category_id) VALUES (?, ?, ?, ?, ?, ?)' ).run(tripId, name, address, lat, lng, cat?.id ?? null); return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid); } // ── getStats — extended ────────────────────────────────────────────────────── describe('getStats — extended', () => { it('ATLAS-UNIT-005: totalDays is calculated when trip has start_date and end_date', async () => { const { user } = createUser(testDb); createTrip(testDb, user.id, { title: 'Short Trip', start_date: '2024-03-01', end_date: '2024-03-03' }); const stats = await getStats(user.id); // March 1, 2, 3 → diff = 2 + 1 = 3 expect(stats.stats.totalDays).toBe(3); }); it('ATLAS-UNIT-006: totalDays is 0 when trip has no dates', async () => { const { user } = createUser(testDb); createTrip(testDb, user.id, { title: 'Dateless' }); const stats = await getStats(user.id); expect(stats.stats.totalDays).toBe(0); }); it('ATLAS-UNIT-007: manually marked country is merged when user has trips but no resolvable places for that country', async () => { const { user } = createUser(testDb); createTrip(testDb, user.id, { title: 'Japan Trip', start_date: '2024-01-01', end_date: '2024-01-10' }); testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP'); const stats = await getStats(user.id); const codes = stats.countries.map((c: any) => c.code); expect(codes).toContain('JP'); const jp = stats.countries.find((c: any) => c.code === 'JP'); expect(jp?.placeCount).toBe(0); }); it('ATLAS-UNIT-008: lastTrip is resolved with a country code when its places have an address', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Past France Trip', start_date: '2023-05-01', end_date: '2023-05-10' }); insertPlace(testDb, trip.id, 'Eiffel Tower', 'Champ de Mars, Paris, France'); const stats = await getStats(user.id); expect(stats.lastTrip).not.toBeNull(); expect(stats.lastTrip!.countryCode).toBe('FR'); }); it('ATLAS-UNIT-009: nextTrip has daysUntil calculated', async () => { const { user } = createUser(testDb); const futureDate = new Date(); futureDate.setFullYear(futureDate.getFullYear() + 1); const futureDateStr = futureDate.toISOString().split('T')[0]; createTrip(testDb, user.id, { title: 'Future Trip', start_date: futureDateStr }); const stats = await getStats(user.id); expect(stats.nextTrip).not.toBeNull(); expect(stats.nextTrip!.daysUntil).toBeGreaterThan(0); }); it('ATLAS-UNIT-010: streak counts consecutive years with trips and firstYear is the earliest', async () => { const { user } = createUser(testDb); const currentYear = new Date().getFullYear(); createTrip(testDb, user.id, { title: 'This Year', start_date: `${currentYear}-06-01`, end_date: `${currentYear}-06-10` }); createTrip(testDb, user.id, { title: 'Last Year', start_date: `${currentYear - 1}-07-01`, end_date: `${currentYear - 1}-07-10` }); const stats = await getStats(user.id); expect(stats.streak).toBeGreaterThanOrEqual(1); expect(stats.firstYear).toBe(currentYear - 1); }); it('ATLAS-UNIT-011: tripsThisYear counts only trips whose start_date is in the current year', async () => { const { user } = createUser(testDb); const currentYear = new Date().getFullYear(); createTrip(testDb, user.id, { title: 'This Year', start_date: `${currentYear}-03-01` }); createTrip(testDb, user.id, { title: 'Last Year', start_date: `${currentYear - 1}-03-01` }); const stats = await getStats(user.id); expect(stats.tripsThisYear).toBe(1); }); it('ATLAS-UNIT-012: lastTrip is null when all trips end in the future', async () => { const { user } = createUser(testDb); const nextYear = new Date().getFullYear() + 1; createTrip(testDb, user.id, { title: 'Future', start_date: `${nextYear}-01-01`, end_date: `${nextYear}-01-10` }); const stats = await getStats(user.id); expect(stats.lastTrip).toBeNull(); }); }); // ── getCountryPlaces ───────────────────────────────────────────────────────── describe('getCountryPlaces', () => { it('ATLAS-UNIT-013: returns empty result when user has no trips', () => { const { user } = createUser(testDb); const result = getCountryPlaces(user.id, 'FR'); expect(result.places).toHaveLength(0); expect(result.trips).toHaveLength(0); expect(result.manually_marked).toBe(false); }); it('ATLAS-UNIT-014: returns matching places when place address resolves to the requested country', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'France Trip' }); insertPlace(testDb, trip.id, 'Louvre', '75001 Paris, France'); insertPlace(testDb, trip.id, 'Berlin Wall', 'Bernauer Str., Berlin, Germany'); const result = getCountryPlaces(user.id, 'FR'); expect(result.places).toHaveLength(1); expect(result.places[0].name).toBe('Louvre'); expect(result.trips).toHaveLength(1); expect(result.trips[0].id).toBe(trip.id); }); it('ATLAS-UNIT-015: manually_marked is true when country is in visited_countries', () => { const { user } = createUser(testDb); testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP'); createTrip(testDb, user.id, { title: 'Japan' }); const result = getCountryPlaces(user.id, 'JP'); expect(result.manually_marked).toBe(true); }); it('ATLAS-UNIT-016: place with coordinates resolves via bbox when address is absent', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Coord Trip' }); // Paris coordinates (48.85°N, 2.35°E) — falls inside FR bounding box insertPlaceWithCoords(testDb, trip.id, 'Secret Paris Spot', 48.85, 2.35); const result = getCountryPlaces(user.id, 'FR'); expect(result.places).toHaveLength(1); expect(result.places[0].name).toBe('Secret Paris Spot'); }); }); // ── getVisitedRegions ──────────────────────────────────────────────────────── describe('getVisitedRegions', () => { it('ATLAS-UNIT-017: returns empty regions object when user has no trips', async () => { const { user } = createUser(testDb); const result = await getVisitedRegions(user.id); expect(result.regions).toEqual({}); }); it('ATLAS-UNIT-018: returns manually marked regions even when user has no places with coordinates', async () => { const { user } = createUser(testDb); testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'DE'); testDb.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(user.id, 'DE-BY', 'Bayern', 'DE'); const result = await getVisitedRegions(user.id); expect(result.regions['DE']).toBeDefined(); const codes = result.regions['DE'].map((r: any) => r.code); expect(codes).toContain('DE-BY'); const bayernRegion = result.regions['DE'].find((r: any) => r.code === 'DE-BY'); expect(bayernRegion?.manuallyMarked).toBe(true); }); it('ATLAS-UNIT-019: geocodes places with lat/lng using reverseGeocodeRegion via fetch', async () => { vi.useFakeTimers(); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ address: { country_code: 'fr', 'ISO3166-2-lvl4': 'FR-75', state: 'Île-de-France', }, }), })); const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); insertPlaceWithCoords(testDb, trip.id, 'Paris Hotel', 48.85, 2.35); const resultPromise = getVisitedRegions(user.id); // Advance all pending timers (including the 1100ms Nominatim rate-limit delay) await vi.runAllTimersAsync(); const result = await resultPromise; expect(result.regions['FR']).toBeDefined(); vi.useRealTimers(); }); it('ATLAS-UNIT-020: places already cached in place_regions are not re-geocoded', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Cached Trip' }); const place = insertPlaceWithCoords(testDb, trip.id, 'Cached Place', 48.85, 2.35); // Pre-populate the place_regions cache so the fetch path is never reached testDb.prepare( 'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)' ).run(place.id, 'FR', 'FR-75', 'Île-de-France'); const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }); vi.stubGlobal('fetch', mockFetch); const result = await getVisitedRegions(user.id); expect(mockFetch).not.toHaveBeenCalled(); expect(result.regions['FR']).toBeDefined(); const codes = result.regions['FR'].map((r: any) => r.code); expect(codes).toContain('FR-75'); }); });