import { describe, it, expect, beforeEach, vi } from 'vitest'; import PlacesModule from '../src/places.js'; import { createMockClient, MockPrivateClient } from './utils.js'; // We need to cast our mock to any because we are not implementing the full BaseClient interface const Places = PlacesModule.builder; describe('Places Module', () => { let mockClient: MockPrivateClient; let moduleInstance: any; beforeEach(() => { mockClient = createMockClient(); moduleInstance = Places(mockClient as any); }); describe('Places Functionality', () => { let places: any; beforeEach(() => { places = moduleInstance.exports; }); describe('store', () => { it('saves a place with the correct path structure', async () => { const placeData = { title: 'Test Place', lat: 52.5, lon: 13.4, }; await places.store(placeData); expect(mockClient.storeObject).toHaveBeenCalledWith( 'place', expect.stringMatching(/^u3\/3d\/.+/), expect.objectContaining({ title: 'Test Place', lat: 52.5, lon: 13.4, }) ); }); }); describe('remove', () => { it('deletes a place at the correct path', async () => { const id = 'some-id'; const geohash = 'u33dc0'; // u3/3d/ mockClient.getAll.mockResolvedValue([]); await places.remove(id, geohash); expect(mockClient.remove).toHaveBeenCalledWith('u3/3d/some-id'); }); it('cleans up references from lists', async () => { const id = 'some-id'; const geohash = 'u33dc0'; const mockLists = { 'to-go': { id: 'to-go', placeRefs: [{ id: 'some-id', geohash: 'u33dc0' }], }, 'to-do': { id: 'to-do', placeRefs: [] }, }; mockClient.getAll.mockResolvedValue(Object.values(mockLists)); await places.remove(id, geohash); expect(mockClient.getAll).toHaveBeenCalledWith('_lists/'); // Expect "to-go" to be updated without the reference expect(mockClient.storeObject).toHaveBeenCalledWith( 'list', '_lists/to-go', expect.objectContaining({ placeRefs: [], }) ); }); }); describe('get', () => { it('retrieves a single place at the correct path', async () => { const id = 'some-id'; const geohash = 'u33dc0'; const mockPlace = { id, geohash, title: 'Test Place' }; mockClient.getObject.mockResolvedValue(mockPlace); const result = await places.get(id, geohash); expect(mockClient.getObject).toHaveBeenCalledWith('u3/3d/some-id'); expect(result).toEqual(mockPlace); }); }); describe('listByPrefix', () => { it('lists subfolders for 2-char prefix', async () => { const prefix = 'u3'; const mockListing = { '3d/': true }; mockClient.getAll.mockResolvedValue(mockListing); const result = await places.listByPrefix(prefix); expect(mockClient.getAll).toHaveBeenCalledWith('u3/'); expect(result).toEqual(mockListing); }); it('lists places for 4-char prefix', async () => { const prefix = 'u33d'; const mockPlaces = { place1: { title: 'Place 1' } }; mockClient.getAll.mockResolvedValue(mockPlaces); const result = await places.listByPrefix(prefix); expect(mockClient.getAll).toHaveBeenCalledWith('u3/3d/'); expect(result).toEqual(mockPlaces); }); }); describe('getPlaces', () => { it('fetches places from specified prefixes', async () => { const prefixes = ['u33d', 'w1q7']; const mockPlaces1 = { p1: { id: 'p1', geohash: 'u33d' } }; const mockPlaces2 = { p2: { id: 'p2', geohash: 'w1q7' } }; mockClient.getAll.mockImplementation(async (path: string) => { if (path === 'u3/3d/') return mockPlaces1; if (path === 'w1/q7/') return mockPlaces2; return {}; }); const result = await places.getPlaces(prefixes); expect(mockClient.getAll).toHaveBeenCalledWith('u3/3d/', false); expect(mockClient.getAll).toHaveBeenCalledWith('w1/q7/', false); expect(result).toHaveLength(2); expect(result).toContainEqual(mockPlaces1['p1']); expect(result).toContainEqual(mockPlaces2['p2']); }); it('recursively fetches all places when no prefix is provided', async () => { // Mock directory structure // root -> 'u3/' // 'u3/' -> '3d/' // 'u3/3d/' -> place1 mockClient.getListing.mockImplementation(async (path: string) => { if (path === '') return { 'u3/': true }; if (path === 'u3/') return { '3d/': true }; return {}; }); const mockPlaces = { place1: { id: 'p1', geohash: 'u33d' } }; mockClient.getAll.mockImplementation(async (path: string) => { if (path === 'u3/3d/') return mockPlaces; return {}; }); const result = await places.getPlaces(); expect(mockClient.getListing).toHaveBeenCalledWith('', false); expect(mockClient.getListing).toHaveBeenCalledWith('u3/', false); expect(mockClient.getAll).toHaveBeenCalledWith('u3/3d/', false); expect(result).toHaveLength(1); expect(result[0]).toEqual(mockPlaces['place1']); }); }); }); describe('Lists Functionality', () => { let lists: any; beforeEach(() => { lists = moduleInstance.exports.lists; }); describe('getAll', () => { it('returns all lists from the _lists/ directory', async () => { const mockLists = { 'to-go': { id: 'to-go', title: 'Want to go' }, hiking: { id: 'hiking', title: 'Hiking' }, }; mockClient.getAll.mockResolvedValue(mockLists); const result = await lists.getAll(); expect(mockClient.getAll).toHaveBeenCalledWith('_lists/'); expect(result).toEqual(Object.values(mockLists)); }); }); describe('get', () => { it('returns a single list by ID', async () => { const mockList = { id: 'hiking', title: 'Hiking' }; mockClient.getObject.mockResolvedValue(mockList); const result = await lists.get('hiking'); expect(mockClient.getObject).toHaveBeenCalledWith('_lists/hiking'); expect(result).toEqual(mockList); }); }); describe('create', () => { it('stores a new list when none exists', async () => { const now = '2023-01-01T00:00:00.000Z'; vi.setSystemTime(new Date(now)); // Mock getObject to return null (not existing) mockClient.getObject.mockResolvedValue(null); const result = await lists.create('hiking', 'Hiking', '#00ff00'); expect(mockClient.getObject).toHaveBeenCalledWith('_lists/hiking'); expect(mockClient.storeObject).toHaveBeenCalledWith( 'list', '_lists/hiking', { id: 'hiking', title: 'Hiking', color: '#00ff00', placeRefs: [], createdAt: now, updatedAt: now, } ); expect(result).toMatchObject({ id: 'hiking', title: 'Hiking' }); vi.useRealTimers(); }); it('updates an existing list preserving creation time and references', async () => { const now = '2023-01-02T00:00:00.000Z'; vi.setSystemTime(new Date(now)); const existing = { id: 'hiking', title: 'Old Title', color: '#ffffff', placeRefs: [{ id: '123', geohash: 'abc' }], createdAt: '2022-01-01T00:00:00.000Z', }; mockClient.getObject.mockResolvedValue(existing); await lists.create('hiking', 'Hiking Updated', '#000000'); expect(mockClient.storeObject).toHaveBeenCalledWith( 'list', '_lists/hiking', expect.objectContaining({ id: 'hiking', title: 'Hiking Updated', color: '#000000', placeRefs: existing.placeRefs, // Should preserve refs createdAt: existing.createdAt, // Should preserve createdAt updatedAt: now, // Should update updatedAt }) ); vi.useRealTimers(); }); }); describe('delete', () => { it('removes the list document', async () => { await lists.delete('hiking'); expect(mockClient.remove).toHaveBeenCalledWith('_lists/hiking'); }); }); describe('addPlace', () => { it('adds a place reference when not present', async () => { const now = '2023-01-03T00:00:00.000Z'; vi.setSystemTime(new Date(now)); const list = { id: 'hiking', placeRefs: [], updatedAt: 'old-date', }; mockClient.getObject.mockResolvedValue(list); await lists.addPlace('hiking', 'place-123', 'w1q7'); expect(mockClient.storeObject).toHaveBeenCalledWith( 'list', '_lists/hiking', expect.objectContaining({ placeRefs: [{ id: 'place-123', geohash: 'w1q7' }], updatedAt: now, }) ); vi.useRealTimers(); }); it('does nothing if place is already present', async () => { const list = { id: 'hiking', placeRefs: [{ id: 'place-123', geohash: 'w1q7' }], updatedAt: 'old-date', }; mockClient.getObject.mockResolvedValue(list); await lists.addPlace('hiking', 'place-123', 'w1q7'); expect(mockClient.storeObject).not.toHaveBeenCalled(); }); }); describe('removePlace', () => { it('removes a place reference when present', async () => { const now = '2023-01-04T00:00:00.000Z'; vi.setSystemTime(new Date(now)); const list = { id: 'hiking', placeRefs: [{ id: 'place-123', geohash: 'w1q7' }], updatedAt: 'old-date', }; mockClient.getObject.mockResolvedValue(list); await lists.removePlace('hiking', 'place-123'); expect(mockClient.storeObject).toHaveBeenCalledWith( 'list', '_lists/hiking', expect.objectContaining({ placeRefs: [], updatedAt: now, }) ); vi.useRealTimers(); }); it('does nothing if place is not present', async () => { const list = { id: 'hiking', placeRefs: [], updatedAt: 'old-date', }; mockClient.getObject.mockResolvedValue(list); await lists.removePlace('hiking', 'place-123'); expect(mockClient.storeObject).not.toHaveBeenCalled(); }); }); describe('initDefaults', () => { it('creates "Want to go" list if missing', async () => { mockClient.getObject.mockResolvedValue(null); await lists.initDefaults(); expect(mockClient.getObject).toHaveBeenCalledWith('_lists/to-go'); expect(mockClient.storeObject).toHaveBeenCalledWith( 'list', '_lists/to-go', expect.objectContaining({ title: 'Want to go', id: 'to-go', color: '#2e9e4f', }) ); }); it('creates "To do" list if missing', async () => { mockClient.getObject.mockResolvedValue(null); await lists.initDefaults(); expect(mockClient.getObject).toHaveBeenCalledWith('_lists/to-do'); expect(mockClient.storeObject).toHaveBeenCalledWith( 'list', '_lists/to-do', expect.objectContaining({ title: 'To do', id: 'to-do', color: '#2a7fff', }) ); }); it('does not overwrite existing lists', async () => { // Mock that "to-go" exists but "to-do" does not mockClient.getObject.mockImplementation(async (path: string) => { if (path === '_lists/to-go') return { id: 'to-go', title: 'Existing' }; return null; }); await lists.initDefaults(); // Should NOT write to-go expect(mockClient.storeObject).not.toHaveBeenCalledWith( 'list', '_lists/to-go', expect.anything() ); // Should write to-do expect(mockClient.storeObject).toHaveBeenCalledWith( 'list', '_lists/to-do', expect.anything() ); }); }); }); });