Place lists #1
@@ -41,6 +41,12 @@ console.log(allPlaces);
|
|||||||
// List places for specific geohash prefixes (e.g. for a map view)
|
// List places for specific geohash prefixes (e.g. for a map view)
|
||||||
const areaPlaces = await places.getPlaces(['u33d', 'u33e']);
|
const areaPlaces = await places.getPlaces(['u33d', 'u33e']);
|
||||||
console.log(areaPlaces);
|
console.log(areaPlaces);
|
||||||
|
|
||||||
|
// Create a list
|
||||||
|
await places.lists.create('favorites', 'My Favorites');
|
||||||
|
|
||||||
|
// Add a place to a list (requires list ID, place ID, and place geohash)
|
||||||
|
await places.lists.addPlace('favorites', 'place-id-123', 'u33dc0');
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
@@ -52,3 +58,4 @@ console.log(areaPlaces);
|
|||||||
### Type Aliases
|
### Type Aliases
|
||||||
|
|
||||||
- [Place](docs/type-aliases/Place.md)
|
- [Place](docs/type-aliases/Place.md)
|
||||||
|
- [List](docs/type-aliases/List.md)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
## Type Aliases
|
## Type Aliases
|
||||||
|
|
||||||
|
- [List](type-aliases/List.md)
|
||||||
- [Place](type-aliases/Place.md)
|
- [Place](type-aliases/Place.md)
|
||||||
|
|
||||||
## Variables
|
## Variables
|
||||||
|
|||||||
@@ -6,6 +6,144 @@
|
|||||||
|
|
||||||
# Interface: PlacesClient
|
# Interface: PlacesClient
|
||||||
|
|
||||||
|
## Properties
|
||||||
|
|
||||||
|
### lists
|
||||||
|
|
||||||
|
> **lists**: `object`
|
||||||
|
|
||||||
|
#### addPlace()
|
||||||
|
|
||||||
|
> **addPlace**(`listId`, `placeId`, `geohash`): `Promise`\<[`List`](../type-aliases/List.md)\>
|
||||||
|
|
||||||
|
Add a place to a list.
|
||||||
|
|
||||||
|
##### Parameters
|
||||||
|
|
||||||
|
###### listId
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
The slug ID of the list.
|
||||||
|
|
||||||
|
###### placeId
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
The ID of the place.
|
||||||
|
|
||||||
|
###### geohash
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
The geohash of the place.
|
||||||
|
|
||||||
|
##### Returns
|
||||||
|
|
||||||
|
`Promise`\<[`List`](../type-aliases/List.md)\>
|
||||||
|
|
||||||
|
#### create()
|
||||||
|
|
||||||
|
> **create**(`id`, `title`, `color?`): `Promise`\<[`List`](../type-aliases/List.md)\>
|
||||||
|
|
||||||
|
Create or update a list.
|
||||||
|
|
||||||
|
##### Parameters
|
||||||
|
|
||||||
|
###### id
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
The slug ID (e.g., "to-go").
|
||||||
|
|
||||||
|
###### title
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
Human readable title.
|
||||||
|
|
||||||
|
###### color?
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
Optional hex color code.
|
||||||
|
|
||||||
|
##### Returns
|
||||||
|
|
||||||
|
`Promise`\<[`List`](../type-aliases/List.md)\>
|
||||||
|
|
||||||
|
#### delete()
|
||||||
|
|
||||||
|
> **delete**(`id`): `Promise`\<`void`\>
|
||||||
|
|
||||||
|
Delete a list.
|
||||||
|
|
||||||
|
##### Parameters
|
||||||
|
|
||||||
|
###### id
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
The slug ID of the list.
|
||||||
|
|
||||||
|
##### Returns
|
||||||
|
|
||||||
|
`Promise`\<`void`\>
|
||||||
|
|
||||||
|
#### get()
|
||||||
|
|
||||||
|
> **get**(`id`): `Promise`\<[`List`](../type-aliases/List.md) \| `null`\>
|
||||||
|
|
||||||
|
Get a single list by ID (slug).
|
||||||
|
|
||||||
|
##### Parameters
|
||||||
|
|
||||||
|
###### id
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
The slug ID of the list.
|
||||||
|
|
||||||
|
##### Returns
|
||||||
|
|
||||||
|
`Promise`\<[`List`](../type-aliases/List.md) \| `null`\>
|
||||||
|
|
||||||
|
#### getAll()
|
||||||
|
|
||||||
|
> **getAll**(): `Promise`\<[`List`](../type-aliases/List.md)[]\>
|
||||||
|
|
||||||
|
Get all lists.
|
||||||
|
|
||||||
|
##### Returns
|
||||||
|
|
||||||
|
`Promise`\<[`List`](../type-aliases/List.md)[]\>
|
||||||
|
|
||||||
|
Array of List objects.
|
||||||
|
|
||||||
|
#### removePlace()
|
||||||
|
|
||||||
|
> **removePlace**(`listId`, `placeId`): `Promise`\<[`List`](../type-aliases/List.md)\>
|
||||||
|
|
||||||
|
Remove a place from a list.
|
||||||
|
|
||||||
|
##### Parameters
|
||||||
|
|
||||||
|
###### listId
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
The slug ID of the list.
|
||||||
|
|
||||||
|
###### placeId
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
The ID of the place.
|
||||||
|
|
||||||
|
##### Returns
|
||||||
|
|
||||||
|
`Promise`\<[`List`](../type-aliases/List.md)\>
|
||||||
|
|
||||||
## Methods
|
## Methods
|
||||||
|
|
||||||
### get()
|
### get()
|
||||||
|
|||||||
9
docs/type-aliases/List.md
Normal file
9
docs/type-aliases/List.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[**@remotestorage/module-places**](../README.md)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
[@remotestorage/module-places](../README.md) / List
|
||||||
|
|
||||||
|
# Type Alias: List
|
||||||
|
|
||||||
|
> **List** = `FromSchema`\<*typeof* `listSchema`\> & `object`
|
||||||
@@ -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
187
src/places.ts
187
src/places.ts
@@ -31,6 +31,32 @@ const placeSchema = {
|
|||||||
required: ['id', 'title', 'lat', 'lon', 'geohash', 'createdAt'],
|
required: ['id', 'title', 'lat', 'lon', 'geohash', 'createdAt'],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const listSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
title: { type: 'string' },
|
||||||
|
color: { type: 'string' },
|
||||||
|
placeRefs: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
geohash: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['id', 'geohash'],
|
||||||
|
},
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
createdAt: { type: 'string', format: 'date-time' },
|
||||||
|
updatedAt: { type: 'string', format: 'date-time' },
|
||||||
|
},
|
||||||
|
required: ['id', 'title', 'placeRefs', 'createdAt'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type List = FromSchema<typeof listSchema> & { [key: string]: any };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a Place object.
|
* Represents a Place object.
|
||||||
*
|
*
|
||||||
@@ -101,6 +127,49 @@ export interface PlacesClient {
|
|||||||
* @returns An array of places.
|
* @returns An array of places.
|
||||||
*/
|
*/
|
||||||
getPlaces(prefixes?: string[]): Promise<Place[]>;
|
getPlaces(prefixes?: string[]): Promise<Place[]>;
|
||||||
|
|
||||||
|
lists: {
|
||||||
|
/**
|
||||||
|
* Get all lists.
|
||||||
|
* @returns Array of List objects.
|
||||||
|
*/
|
||||||
|
getAll(): Promise<List[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single list by ID (slug).
|
||||||
|
* @param id - The slug ID of the list.
|
||||||
|
*/
|
||||||
|
get(id: string): Promise<List | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update a list.
|
||||||
|
* @param id - The slug ID (e.g., "to-go").
|
||||||
|
* @param title - Human readable title.
|
||||||
|
* @param color - Optional hex color code.
|
||||||
|
*/
|
||||||
|
create(id: string, title: string, color?: string): Promise<List>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a list.
|
||||||
|
* @param id - The slug ID of the list.
|
||||||
|
*/
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a place to a list.
|
||||||
|
* @param listId - The slug ID of the list.
|
||||||
|
* @param placeId - The ID of the place.
|
||||||
|
* @param geohash - The geohash of the place.
|
||||||
|
*/
|
||||||
|
addPlace(listId: string, placeId: string, geohash: string): Promise<List>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a place from a list.
|
||||||
|
* @param listId - The slug ID of the list.
|
||||||
|
* @param placeId - The ID of the place.
|
||||||
|
*/
|
||||||
|
removePlace(listId: string, placeId: string): Promise<List>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const Places = function (
|
const Places = function (
|
||||||
@@ -108,6 +177,7 @@ const Places = function (
|
|||||||
): { exports: PlacesClient } {
|
): { exports: PlacesClient } {
|
||||||
// Define Schema
|
// Define Schema
|
||||||
privateClient.declareType('place', placeSchema as any);
|
privateClient.declareType('place', placeSchema as any);
|
||||||
|
privateClient.declareType('list', listSchema as any);
|
||||||
|
|
||||||
// Helper to normalize place object
|
// Helper to normalize place object
|
||||||
function preparePlace(data: Partial<Place>): Place {
|
function preparePlace(data: Partial<Place>): Place {
|
||||||
@@ -153,7 +223,110 @@ const Places = function (
|
|||||||
return `${p1}/${p2}/${id}`;
|
return `${p1}/${p2}/${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lists = {
|
||||||
|
async getAll(): Promise<List[]> {
|
||||||
|
const result = await privateClient.getAll('_lists/');
|
||||||
|
if (!result) return [];
|
||||||
|
// Normalize result: remoteStorage.getAll returns { 'slug': object }
|
||||||
|
return Object.values(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<List | null> {
|
||||||
|
const path = `_lists/${id}`;
|
||||||
|
return privateClient.getObject(path) as Promise<List | null>;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(id: string, title: string, color?: string): Promise<List> {
|
||||||
|
const path = `_lists/${id}`;
|
||||||
|
let list = (await privateClient.getObject(path)) as List;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (list) {
|
||||||
|
// Update existing
|
||||||
|
list.title = title;
|
||||||
|
if (color) list.color = color;
|
||||||
|
list.updatedAt = now;
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
list = {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
color,
|
||||||
|
placeRefs: [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} as List;
|
||||||
|
}
|
||||||
|
|
||||||
|
await privateClient.storeObject('list', path, list);
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await privateClient.remove(`_lists/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async addPlace(
|
||||||
|
listId: string,
|
||||||
|
placeId: string,
|
||||||
|
geohash: string
|
||||||
|
): Promise<List> {
|
||||||
|
const path = `_lists/${listId}`;
|
||||||
|
const list = (await privateClient.getObject(path)) as List;
|
||||||
|
|
||||||
|
if (!list) {
|
||||||
|
throw new Error(`List not found: ${listId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = list.placeRefs.findIndex((ref: any) => ref.id === placeId);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
// Add only if not present
|
||||||
|
list.placeRefs.push({ id: placeId, geohash });
|
||||||
|
list.updatedAt = new Date().toISOString();
|
||||||
|
await privateClient.storeObject('list', path, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
|
||||||
|
async removePlace(listId: string, placeId: string): Promise<List> {
|
||||||
|
const path = `_lists/${listId}`;
|
||||||
|
const list = (await privateClient.getObject(path)) as List;
|
||||||
|
|
||||||
|
if (!list) {
|
||||||
|
throw new Error(`List not found: ${listId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = list.placeRefs.findIndex((ref: any) => ref.id === placeId);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
// Remove only if present
|
||||||
|
list.placeRefs.splice(index, 1);
|
||||||
|
list.updatedAt = new Date().toISOString();
|
||||||
|
await privateClient.storeObject('list', path, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
|
||||||
|
async initDefaults(): Promise<void> {
|
||||||
|
const defaults = [
|
||||||
|
{ id: 'to-go', title: 'Want to go', color: '#2e9e4f' }, // Green
|
||||||
|
{ id: 'to-do', title: 'To do', color: '#2a7fff' }, // Blue
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const def of defaults) {
|
||||||
|
const existing = await this.get(def.id);
|
||||||
|
if (!existing) {
|
||||||
|
await this.create(def.id, def.title, def.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const places = {
|
const places = {
|
||||||
|
lists,
|
||||||
/**
|
/**
|
||||||
* Store a place.
|
* Store a place.
|
||||||
* Generates ID and Geohash if missing.
|
* Generates ID and Geohash if missing.
|
||||||
@@ -174,6 +347,20 @@ const Places = function (
|
|||||||
if (!id || !geohash) {
|
if (!id || !geohash) {
|
||||||
throw new Error('Both id and geohash are required to remove a place');
|
throw new Error('Both id and geohash are required to remove a place');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup: Remove this place from all lists
|
||||||
|
const allLists = await lists.getAll();
|
||||||
|
await Promise.all(
|
||||||
|
allLists.map(async (list) => {
|
||||||
|
const index = list.placeRefs.findIndex((ref: any) => ref.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
list.placeRefs.splice(index, 1);
|
||||||
|
list.updatedAt = new Date().toISOString();
|
||||||
|
await privateClient.storeObject('list', `_lists/${list.id}`, list);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const path = getPath(geohash, id);
|
const path = getPath(geohash, id);
|
||||||
return privateClient.remove(path);
|
return privateClient.remove(path);
|
||||||
},
|
},
|
||||||
|
|||||||
414
test/places.test.ts
Normal file
414
test/places.test.ts
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
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()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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