Add test suite, cover existing functions
This commit is contained in:
@@ -20,7 +20,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rimraf dist && tsc",
|
"build": "rimraf dist && tsc",
|
||||||
"doc": "typedoc",
|
"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"
|
"version": "pnpm run build && pnpm run doc && git add dist docs README.md"
|
||||||
},
|
},
|
||||||
"author": "Râu Cao <raucao@kosmos.org>",
|
"author": "Râu Cao <raucao@kosmos.org>",
|
||||||
@@ -31,7 +33,8 @@
|
|||||||
"rimraf": "^6.1.2",
|
"rimraf": "^6.1.2",
|
||||||
"typedoc": "^0.28.16",
|
"typedoc": "^0.28.16",
|
||||||
"typedoc-plugin-markdown": "^4.9.0",
|
"typedoc-plugin-markdown": "^4.9.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"latlon-geohash": "^2.0.0",
|
"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