Place lists #1
@@ -20,7 +20,9 @@
|
||||
"scripts": {
|
||||
"build": "rimraf dist && tsc",
|
||||
"doc": "typedoc",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preversion": "pnpm test",
|
||||
"version": "pnpm run build && pnpm run doc && git add dist docs README.md"
|
||||
},
|
||||
"author": "Râu Cao <raucao@kosmos.org>",
|
||||
@@ -31,7 +33,8 @@
|
||||
"rimraf": "^6.1.2",
|
||||
"typedoc": "^0.28.16",
|
||||
"typedoc-plugin-markdown": "^4.9.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"latlon-geohash": "^2.0.0",
|
||||
|
||||
882
pnpm-lock.yaml
generated
882
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
325
test/places.test.ts
Normal file
325
test/places.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
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/
|
||||
|
||||
await places.remove(id, geohash);
|
||||
|
||||
expect(mockClient.remove).toHaveBeenCalledWith('u3/3d/some-id');
|
||||
});
|
||||
});
|
||||
|
||||
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('togglePlace', () => {
|
||||
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.togglePlace('hiking', 'place-123', 'w1q7');
|
||||
|
||||
expect(mockClient.storeObject).toHaveBeenCalledWith(
|
||||
'list',
|
||||
'_lists/hiking',
|
||||
expect.objectContaining({
|
||||
placeRefs: [{ id: 'place-123', geohash: 'w1q7' }],
|
||||
updatedAt: now,
|
||||
})
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
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.togglePlace('hiking', 'place-123', 'w1q7');
|
||||
|
||||
expect(mockClient.storeObject).toHaveBeenCalledWith(
|
||||
'list',
|
||||
'_lists/hiking',
|
||||
expect.objectContaining({
|
||||
placeRefs: [],
|
||||
updatedAt: now,
|
||||
})
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initDefaults', () => {
|
||||
it('creates default lists if they do not exist', async () => {
|
||||
// Mock getObject to return null for both
|
||||
mockClient.getObject.mockResolvedValue(null);
|
||||
|
||||
await lists.initDefaults();
|
||||
|
||||
// Should check and create "to-go"
|
||||
expect(mockClient.getObject).toHaveBeenCalledWith('_lists/to-go');
|
||||
expect(mockClient.storeObject).toHaveBeenCalledWith(
|
||||
'list',
|
||||
'_lists/to-go',
|
||||
expect.objectContaining({
|
||||
title: 'Want to go',
|
||||
})
|
||||
);
|
||||
|
||||
// Should check and create "to-do"
|
||||
expect(mockClient.getObject).toHaveBeenCalledWith('_lists/to-do');
|
||||
expect(mockClient.storeObject).toHaveBeenCalledWith(
|
||||
'list',
|
||||
'_lists/to-do',
|
||||
expect.objectContaining({
|
||||
title: 'To do',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
16
test/utils.ts
Normal file
16
test/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export class MockPrivateClient {
|
||||
storeObject = vi.fn();
|
||||
remove = vi.fn();
|
||||
getObject = vi.fn();
|
||||
getAll = vi.fn();
|
||||
getListing = vi.fn();
|
||||
declareType = vi.fn();
|
||||
|
||||
// Helper to verify calls easily if needed directly
|
||||
}
|
||||
|
||||
export function createMockClient() {
|
||||
return new MockPrivateClient();
|
||||
}
|
||||
11
vitest.config.ts
Normal file
11
vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node', // Using node environment as we are testing logic not browser specifics
|
||||
include: ['test/**/*.test.ts'],
|
||||
deps: {
|
||||
interopDefault: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user