From 633653a23f9d73e787f229c58069180b8cf5837d Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:23:46 +0200 Subject: [PATCH 1/3] 01a117b8 - Filter map places by country, shop, blockchain, and asset GET /map/places applies a stored country, a shop name, and one blockchain and asset pair in SQL before the limit. GET /map/filters lists those values. Existing pins keep a null country until a real value is written. --- README.md | 2 +- docs/handbook/endpoints.md | 11 +- docs/handbook/functions.md | 38 ++- docs/schema/map-place.sql | 13 + e2e/map.spec.ts | 110 +++++++ src/__tests__/map/btcmap.test.ts | 3 + src/__tests__/map/place.test.ts | 342 +++++++++++++++++++- src/__tests__/map/routes.test.ts | 183 +++++++++++ src/__tests__/map/store.test.ts | 223 ++++++++++++- src/lib/map/place.ts | 540 ++++++++++++++++++++++++++++++- src/lib/map/sqlite.ts | 284 ++++++++++++++-- src/lib/map/store.ts | 180 ++++++++++- src/routes/map.ts | 53 ++- src/server.ts | 2 +- 14 files changed, 1920 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 83e82bd8f..52ded0019 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenCryptoPay API -Map API. `GET /map/places` is the public list of places, each with a tech provider. `POST /map/places` adds a place once. `PUT /map/places` updates a place. `DELETE /map/places` removes a place. +Map API. `GET /map/places` is the public list of places, each with a tech provider; `?origin=` limits that list to one origin. The list can also be limited by country, shopName (`SPAR` or `others`), blockchain, and asset; country is a stored column (the DFX country symbol), blockchain is a DFX payment-network name, and asset is the DFX asset name. `GET /map/filters` lists those filter values. `POST /map/places` adds a place once. `PUT /map/places` updates a place. `DELETE /map/places` removes a place. The same `origin` and `externalId` returns the first pin and does not submit it again. When `BTCMAP_ACCESS_TOKEN` is set, a new pin is posted once to `https://api.btcmap.org/v4/place-submissions`. A missing token stores the pin and skips that call. diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 86e21944f..a794ef68e 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -9,11 +9,18 @@ ## Endpoint: GET /map/places -- **Purpose:** Public map list, newest first. Each item is `id`, `origin`, `name`, `lat`, `lon`, `category`, and `techProvider`. The caller id and payment methods are not included. -- **Errors:** 400 `{ error: "Invalid limit" }` when `limit` is not an integer from 1 to 1000. Omitted `limit` means 1000. +- **Purpose:** Public map list, newest first. Each item is `id`, `origin`, `name`, `lat`, `lon`, `category`, `techProvider`, `country`, `shopName`, and `supports`. `country` is the stored DFX country symbol or null. `shopName` is the stored brand or null. `supports` is the stored blockchain and asset pairs, sorted. The caller id and payment methods are not included. Omitted `origin` is every pin. A present `origin` must be the same token as a stored origin. Optional `country`, `shopName` (`SPAR` or `others`), `blockchain`, and `asset` further restrict the store read. All predicates are AND and run before `limit`. `country` is a stored column, not inferred from an address. +- **Errors:** 400 `{ error: "Invalid limit" }` when `limit` is not an integer from 1 to 1000. Omitted `limit` means 1000. 400 `{ error: "Place origin is invalid" }` when `origin` is present but not a valid origin token. 400 `{ error: "Place country is invalid" }` when `country` is present but not an assigned ISO 3166-1 alpha-2 DFX country symbol. 400 `{ error: "Place shop name is invalid" }` when `shopName` is present and not `SPAR` or `others`. 400 `{ error: "Place support is invalid" }` when `blockchain` or `asset` is present but not a DFX payment-network name or DFX asset name. - **Used by:** The OpenCryptoPay map. - **Auth:** none. Browser calls are allowed from any origin. +## Endpoint: GET /map/filters + +- **Purpose:** Public list of filter values for the map. `shopNames` is always exactly `["SPAR", "others"]`. `countries` are the distinct stored ISO country codes, sorted. `blockchains` are the distinct support blockchains, sorted. `assets` are the distinct support assets, sorted. `?blockchain=` limits `assets` to that chain and still returns every blockchain. +- **Errors:** 400 `{ error: "Place support is invalid" }` when `blockchain` is present but not a DFX payment-network name. +- **Used by:** The OpenCryptoPay map filter UI. +- **Auth:** none. Browser calls are allowed from any origin. + ## Endpoint: POST /map/places - **Purpose:** Create one map pin. The same `origin` and `externalId` returns the first row, including its tech provider, and does not call BTC Map again. A new row is posted once when `BTCMAP_ACCESS_TOKEN` is set. An omitted tech provider is stored as `DFX.swiss`. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 024b36313..8c004302d 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -1,12 +1,26 @@ # Functions +## Function: normalizePlaceOrigin + +- **Purpose:** Trim a pin origin and accept only `^[a-z][a-z0-9-]{0,31}$`. Uppercase tokens such as SPAR are invalid. +- **Inputs:** Unknown value, typically a JSON field or the `origin` query string. +- **Returns / side effects:** `{ ok: true, value }` with the trimmed origin, or `{ ok: false, error: "Place origin is invalid" }`. No I/O. +- **Used by:** `normalizeMapPlace`, `normalizeMapPlaceKey`, and `mapRoutes` on `GET /map/places`. + ## Function: normalizeMapPlace -- **Purpose:** Validate a JSON body for one map pin. Coordinates must be finite and in range, then they are rounded to six decimals. An unknown payment list becomes null instead of an error. An omitted tech provider is left unset so the store can default it. -- **Inputs:** Unknown JSON. Required fields are `origin`, `externalId`, `name`, `lat`, `lon`, and `category`. `techProvider` is optional. +- **Purpose:** Validate a JSON body for one map pin. Coordinates must be finite and in range, then they are rounded to six decimals. An unknown payment list becomes null instead of an error. An omitted tech provider is left unset so the store can default it. Optional `country` is the DFX country symbol (ISO 3166-1 alpha-2), trimmed and uppercased, or omitted. Optional `shopName` is a brand of 1 to 40 characters, not casefolded. Optional `supports` is a list of DFX payment-network and asset-name pairs; omitted leaves the field unset so an upsert can keep the stored rows. +- **Inputs:** Unknown JSON. Required fields are `origin`, `externalId`, `name`, `lat`, `lon`, and `category`. `techProvider`, `country`, `shopName`, and `supports` are optional. - **Returns / side effects:** `{ ok: true, value }` or `{ ok: false, error }` with a fixed English message. No I/O. - **Used by:** `mapRoutes` on `POST /map/places` and `PUT /map/places`. +## Function: normalizeMapPlaceFilter + +- **Purpose:** Validate the optional `country`, `shopName`, `blockchain`, and `asset` query strings for the public map. Omitted parameters stay off the filter. `country` is trimmed and uppercased. `shopName` is trimmed and must be `SPAR` or `others`. `blockchain` and `asset` are trimmed and not casefolded. +- **Inputs:** Four optional raw query strings. +- **Returns / side effects:** `{ ok: true, value }` with the present filter fields, or `{ ok: false, error }` with `Place country is invalid`, `Place shop name is invalid`, or `Place support is invalid`. No I/O. +- **Used by:** `mapRoutes` on `GET /map/places` and `GET /map/filters`. + ## Function: normalizeMapPlaceKey - **Purpose:** Validate origin and external id for deleting a map pin. Extra JSON fields are ignored. The body does not need coordinates or a name. @@ -16,23 +30,23 @@ ## Function: toPublicMapPlace -- **Purpose:** Strip the caller id and payment methods before a pin is shown on the public map. The tech provider stays on the public row. +- **Purpose:** Strip the caller id and payment methods before a pin is shown on the public map. The tech provider, country, shop name, and supports stay on the public row. - **Inputs:** A stored map pin. -- **Returns / side effects:** `id`, `origin`, `name`, `lat`, `lon`, `category`, and `techProvider`. No I/O. +- **Returns / side effects:** `id`, `origin`, `name`, `lat`, `lon`, `category`, `techProvider`, `country`, `shopName`, and `supports`. No I/O. - **Used by:** `mapRoutes` on `GET /map/places`. ## Function: MemoryMapPlaceStore -- **Purpose:** In-memory map store with the same insert-once, upsert, and delete rules as SQLite. A repeat of origin plus external id on insert returns the first row unchanged, including its tech provider. -- **Inputs:** `insertIfNew` and `upsert` take a validated pin. `deleteByKey` takes origin and external id. `list` takes a positive limit. -- **Returns / side effects:** `insertIfNew` reports whether it created the row. `upsert` inserts or updates. `deleteByKey` is true only when a row was removed. `list` is newest first, then id descending. `close` drops the rows. +- **Purpose:** In-memory map store with the same insert-once, upsert, and delete rules as SQLite. A repeat of origin plus external id on insert returns the first row unchanged, including its tech provider, country, shop name, and supports. +- **Inputs:** `insertIfNew` and `upsert` take a validated pin. `deleteByKey` takes origin and external id. `list` takes a positive limit and an optional filter object (`origin`, `country`, `shopName`, `blockchain`, `asset`). `filters` takes an optional blockchain. +- **Returns / side effects:** `insertIfNew` reports whether it created the row. `upsert` inserts or updates country and shop name from the body (`null` when omitted) and replaces supports only when that field is sent. `deleteByKey` is true only when a row was removed. `list` is newest first, then id descending. Predicates are AND and apply before sort and limit. `shopName` `SPAR` matches that stored brand; `others` matches a non-null brand other than SPAR. Blockchain and asset must occur on the same support row. `filters` returns distinct stored countries, blockchains, and assets; shop names are not read. `close` drops the rows. - **Used by:** Unit tests. The process uses `SqliteMapPlaceStore`. ## Function: SqliteMapPlaceStore -- **Purpose:** SQLite driver for map pins. The schema is `MAP_PLACE_SCHEMA_SQL`. Opening an older file adds `tech_provider` when that column is missing. A conflicting insert does not overwrite the first row. -- **Inputs:** Constructor takes a SQLite filename or `:memory:`. `upsert` and `deleteByKey` use the same origin and external id pair as insert. -- **Returns / side effects:** Opens the file, applies the schema, and implements `MapPlaceStore`. `close` closes the handle. +- **Purpose:** SQLite driver for map pins. The schema is `MAP_PLACE_SCHEMA_SQL`. Opening an older file adds `tech_provider`, `country`, and `shop_name` when those columns are missing, creates `map_place_support` and its indexes if missing, and backfills `shop_name` to SPAR for origin `spar` when that column is null. A conflicting insert does not overwrite the first row. +- **Inputs:** Constructor takes a SQLite filename or `:memory:`. `upsert` and `deleteByKey` use the same origin and external id pair as insert. `list` takes a positive limit and an optional filter object. Predicates are AND and apply before sort and limit. SQLite binds every value. `filters` takes an optional blockchain. +- **Returns / side effects:** Opens the file, enables foreign keys, applies the schema, and implements `MapPlaceStore`. Present filter fields are AND-ed before the sort and the limit. Deleting a pin deletes its support rows. `close` closes the handle. - **Used by:** The process entrypoint. The HTTP end-to-end run exercises it. ## Function: mapSubmissionBody @@ -51,9 +65,9 @@ ## Function: mapRoutes -- **Purpose:** `GET /map/places` is public. `POST /map/places` requires the ingest bearer and creates a pin at most once. `PUT /map/places` upserts a pin. `DELETE /map/places` removes a pin by origin and external id. +- **Purpose:** `GET /map/places` is public and may take `origin`, `country`, `shopName`, `blockchain`, and `asset` to restrict the list. `GET /map/filters` is public and returns the shop name tokens plus distinct countries, blockchains, and assets. `POST /map/places` requires the ingest bearer and creates a pin at most once. `PUT /map/places` upserts a pin. `DELETE /map/places` removes a pin by origin and external id. - **Inputs:** Store, optional ingest token, environment, and fetch. -- **Returns / side effects:** GET returns `{ places }`. POST and PUT return 201 `{ created: true, id, btcmap }` or 200 `{ created: false, id, btcmap: "skipped" }`. DELETE returns 200 `{ deleted: true }` or `{ deleted: false }`. +- **Returns / side effects:** GET `/places` returns `{ places }`. GET `/filters` returns `{ shopNames, countries, blockchains, assets }`. POST and PUT return 201 `{ created: true, id, btcmap }` or 200 `{ created: false, id, btcmap: "skipped" }`. DELETE returns 200 `{ deleted: true }` or `{ deleted: false }`. - **Used by:** `createApp`, mounted at `/map`. ## Function: healthRoutes diff --git a/docs/schema/map-place.sql b/docs/schema/map-place.sql index 976ec2a25..9ab57acc5 100644 --- a/docs/schema/map-place.sql +++ b/docs/schema/map-place.sql @@ -10,5 +10,18 @@ CREATE TABLE IF NOT EXISTS map_place ( payment_methods TEXT, created_at TEXT NOT NULL, tech_provider TEXT NOT NULL DEFAULT 'DFX.swiss', + country TEXT, + shop_name TEXT, UNIQUE (origin, external_id) ); +CREATE TABLE IF NOT EXISTS map_place_support ( + place_id TEXT NOT NULL, + blockchain TEXT NOT NULL, + asset TEXT NOT NULL, + PRIMARY KEY (place_id, blockchain, asset), + FOREIGN KEY (place_id) REFERENCES map_place(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS map_place_country ON map_place(country); +CREATE INDEX IF NOT EXISTS map_place_shop_name ON map_place(shop_name); +CREATE INDEX IF NOT EXISTS map_place_support_lookup + ON map_place_support(blockchain, asset, place_id); diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 290737abe..8899df6c2 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -23,6 +23,54 @@ test('Function: mapRoutes — GET /map/places is public', async ({ request }) => expect(Array.isArray(json.places)).toBe(true); }); +test('Function: normalizePlaceOrigin — GET /map/places?origin=spar filters the list', async ({ + request, +}) => { + const sparPin = { + origin: 'spar', + externalId: 'e2e-origin-spar', + name: 'Origin SPAR Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + }; + const otherPin = { + origin: 'dfx', + externalId: 'e2e-origin-other', + name: 'Origin Other Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: sparPin, + }) + ).status(), + ).toBe(201); + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: otherPin, + }) + ).status(), + ).toBe(201); + + const filtered = await request.get('/map/places?origin=spar'); + expect(filtered.status()).toBe(200); + const json = (await filtered.json()) as { places: Array<{ origin: string; name: string }> }; + expect(json.places.some((row) => row.name === 'Origin SPAR Pin')).toBe(true); + expect(json.places.some((row) => row.name === 'Origin Other Pin')).toBe(false); + expect(json.places.every((row) => row.origin === 'spar')).toBe(true); + + const bad = await request.get('/map/places?origin=SPAR'); + expect(bad.status()).toBe(400); + expect(await bad.json()).toEqual({ error: 'Place origin is invalid' }); +}); + test('Function: createApp — POST /map/places without a bearer is 401', async ({ request }) => { const res = await request.post('/map/places', { data: body }); expect(res.status()).toBe(401); @@ -82,6 +130,68 @@ test('Function: pushMapPlace — default boot does not require a map token', asy expect(res.status()).toBe(200); }); +test('Function: normalizeMapPlaceFilter — filters one row', async ({ request }) => { + const sparPin = { + origin: 'dfx', + externalId: 'e2e-filter-spar-ch', + name: 'Filter SPAR CH', + lat: 47.37, + lon: 8.54, + category: 'groceries', + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }; + const otherPin = { + origin: 'dfx', + externalId: 'e2e-filter-other', + name: 'Filter Other Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + country: 'DE', + shopName: 'Migros', + supports: [{ blockchain: 'Bitcoin', asset: 'BTC' }], + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: sparPin, + }) + ).status(), + ).toBe(201); + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: otherPin, + }) + ).status(), + ).toBe(201); + + const filtered = await request.get( + '/map/places?shopName=SPAR&country=CH&blockchain=Ethereum&asset=ZCHF', + ); + expect(filtered.status()).toBe(200); + const listed = (await filtered.json()) as { places: Array<{ name: string }> }; + expect(listed.places.some((row) => row.name === 'Filter SPAR CH')).toBe(true); + expect(listed.places.some((row) => row.name === 'Filter Other Pin')).toBe(false); + + const filters = await request.get('/map/filters'); + expect(filters.status()).toBe(200); + const filterJson = (await filters.json()) as { + shopNames: string[]; + countries: string[]; + blockchains: string[]; + assets: string[]; + }; + expect(filterJson.shopNames).toEqual(['SPAR', 'others']); + expect(filterJson.countries).toContain('CH'); + expect(filterJson.blockchains).toContain('Ethereum'); + expect(filterJson.assets).toContain('ZCHF'); +}); + test('Function: normalizeMapPlaceKey — PUT updates a pin and DELETE removes it', async ({ request, }) => { diff --git a/src/__tests__/map/btcmap.test.ts b/src/__tests__/map/btcmap.test.ts index f0edb8725..1fdb893d1 100644 --- a/src/__tests__/map/btcmap.test.ts +++ b/src/__tests__/map/btcmap.test.ts @@ -12,6 +12,9 @@ const place: StoredMapPlace = { category: 'groceries', paymentMethods: 'lightning', techProvider: 'DFX.swiss', + country: null, + shopName: null, + supports: [], createdAt: '2026-09-26T00:00:00.000Z', }; diff --git a/src/__tests__/map/place.test.ts b/src/__tests__/map/place.test.ts index 689f2af23..aa9c22e6c 100644 --- a/src/__tests__/map/place.test.ts +++ b/src/__tests__/map/place.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { normalizeMapPlace, normalizeMapPlaceKey, toPublicMapPlace } from '@/lib/map/place'; +import { + normalizeMapPlace, + normalizeMapPlaceFilter, + normalizeMapPlaceKey, + normalizePlaceOrigin, + toPublicMapPlace, +} from '@/lib/map/place'; import type { StoredMapPlace } from '@/lib/map/place'; const valid = { @@ -12,6 +18,27 @@ const valid = { paymentMethods: 'lightning', }; +describe('normalizePlaceOrigin', () => { + it('trims a valid origin', () => { + expect(normalizePlaceOrigin(' spar ')).toEqual({ ok: true, value: 'spar' }); + }); + + it('rejects SPAR and any other invalid value', () => { + expect(normalizePlaceOrigin('SPAR')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin(1)).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin('')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + }); +}); + describe('normalizeMapPlace', () => { it('rejects a non-object and an array', () => { expect(normalizeMapPlace(null)).toEqual({ @@ -123,6 +150,285 @@ describe('normalizeMapPlace', () => { error: 'Place tech provider is invalid', }); }); + + it('omits an absent or null country and stores a trimmed lowercase code as uppercase', () => { + const absent = normalizeMapPlace(valid); + expect(absent.ok && !('country' in absent.value)).toBe(true); + const nulled = normalizeMapPlace({ ...valid, country: null }); + expect(nulled.ok && !('country' in nulled.value)).toBe(true); + const trimmed = normalizeMapPlace({ ...valid, country: ' ch ' }); + expect(trimmed).toEqual({ ok: true, value: { ...valid, country: 'CH' } }); + for (const country of ['CH', 'LI', 'DE', 'AT', 'FR', 'IT', 'PH']) { + const result = normalizeMapPlace({ ...valid, country }); + expect(result).toEqual({ ok: true, value: { ...valid, country } }); + } + }); + + it('rejects Switzerland, C, empty, CHE, ZZ, and a non-string country', () => { + expect(normalizeMapPlace({ ...valid, country: 'Switzerland' })).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlace({ ...valid, country: 'C' })).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlace({ ...valid, country: '' })).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlace({ ...valid, country: 'CHE' })).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlace({ ...valid, country: 'ZZ' })).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlace({ ...valid, country: 1 })).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + }); + + it('omits an absent or null shop name and trims a present brand', () => { + const absent = normalizeMapPlace(valid); + expect(absent.ok && !('shopName' in absent.value)).toBe(true); + const nulled = normalizeMapPlace({ ...valid, shopName: null }); + expect(nulled.ok && !('shopName' in nulled.value)).toBe(true); + const trimmed = normalizeMapPlace({ ...valid, shopName: ' SPAR ' }); + expect(trimmed).toEqual({ ok: true, value: { ...valid, shopName: 'SPAR' } }); + const other = normalizeMapPlace({ ...valid, shopName: 'Migros' }); + expect(other.ok && other.value.shopName).toBe('Migros'); + const max = normalizeMapPlace({ ...valid, shopName: 'x'.repeat(40) }); + expect(max.ok && max.value.shopName).toBe('x'.repeat(40)); + }); + + it('rejects an empty, too long, control, or non-string shop name', () => { + expect(normalizeMapPlace({ ...valid, shopName: '' })).toEqual({ + ok: false, + error: 'Place shop name is invalid', + }); + expect(normalizeMapPlace({ ...valid, shopName: ' ' })).toEqual({ + ok: false, + error: 'Place shop name is invalid', + }); + expect(normalizeMapPlace({ ...valid, shopName: 'x'.repeat(41) })).toEqual({ + ok: false, + error: 'Place shop name is invalid', + }); + expect(normalizeMapPlace({ ...valid, shopName: 'bad\nname' })).toEqual({ + ok: false, + error: 'Place shop name is invalid', + }); + expect(normalizeMapPlace({ ...valid, shopName: 1 })).toEqual({ + ok: false, + error: 'Place shop name is invalid', + }); + }); + + it('omits supports when absent and stores an empty list when sent', () => { + const absent = normalizeMapPlace(valid); + expect(absent.ok && !('supports' in absent.value)).toBe(true); + const empty = normalizeMapPlace({ ...valid, supports: [] }); + expect(empty).toEqual({ ok: true, value: { ...valid, supports: [] } }); + }); + + it('stores a duplicate support pair once and sorts by blockchain then asset', () => { + const result = normalizeMapPlace({ + ...valid, + supports: [ + { blockchain: 'Polygon', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + ], + }); + expect(result).toEqual({ + ok: true, + value: { + ...valid, + supports: [ + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Polygon', asset: 'ZCHF' }, + ], + }, + }); + }); + + it('stores a trimmed dEURO asset unchanged', () => { + const result = normalizeMapPlace({ + ...valid, + supports: [{ blockchain: ' Ethereum ', asset: ' dEURO ' }], + }); + expect(result).toEqual({ + ok: true, + value: { ...valid, supports: [{ blockchain: 'Ethereum', asset: 'dEURO' }] }, + }); + }); + + it('rejects too many support items, a non-array, and a bad item', () => { + const many = Array.from({ length: 33 }, (_, i) => ({ + blockchain: 'Ethereum', + asset: `A${String(i).padStart(2, '0')}`, + })); + expect(normalizeMapPlace({ ...valid, supports: many })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + const thirtyTwo = many.slice(0, 32); + expect(normalizeMapPlace({ ...valid, supports: thirtyTwo }).ok).toBe(true); + expect(normalizeMapPlace({ ...valid, supports: null })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlace({ ...valid, supports: 'Ethereum' })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlace({ ...valid, supports: [null] })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlace({ ...valid, supports: [[]] })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlace({ ...valid, supports: [1] })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlace({ ...valid, supports: [{ blockchain: 'Ethereum' }] })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlace({ ...valid, supports: [{ asset: 'ZCHF' }] })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + }); + + it('rejects payment providers, exchanges, banks, and a manual position as blockchain', () => { + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'BinancePay', asset: 'USDT' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'Kraken', asset: 'BTC' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'PLoan', asset: 'CHF' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'ethereum', asset: 'ZCHF' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'ETHEREUM', asset: 'ZCHF' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'Frick', asset: 'ZCHF' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + }); + + it('rejects lowercase, uniqueName, and invalid assets', () => { + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'Ethereum', asset: 'zchf' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'Ethereum', asset: 'deuro' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'Spark', asset: 'Spark/BTC' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'Ethereum', asset: 'Ethereum/ZCHF' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'BinanceSmartChain', asset: 'opBNB' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + }); +}); + +describe('normalizeMapPlaceFilter', () => { + it('returns an empty filter when every query is omitted', () => { + expect(normalizeMapPlaceFilter()).toEqual({ ok: true, value: {} }); + }); + + it('uppercases a trimmed country and accepts SPAR or others as shopName', () => { + expect(normalizeMapPlaceFilter(' ch ', ' SPAR ', ' Ethereum ', ' dEURO ')).toEqual({ + ok: true, + value: { + country: 'CH', + shopName: 'SPAR', + blockchain: 'Ethereum', + asset: 'dEURO', + }, + }); + expect(normalizeMapPlaceFilter(undefined, ' others ')).toEqual({ + ok: true, + value: { shopName: 'others' }, + }); + }); + + it('rejects a bad country, shop name, blockchain, or asset query', () => { + expect(normalizeMapPlaceFilter('ZZ')).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlaceFilter('CHE')).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlaceFilter(undefined, 'Migros')).toEqual({ + ok: false, + error: 'Place shop name is invalid', + }); + expect(normalizeMapPlaceFilter(undefined, undefined, 'Frick')).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlaceFilter(undefined, undefined, 'ethereum')).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlaceFilter(undefined, undefined, undefined, 'Ethereum/ZCHF')).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + }); }); describe('normalizeMapPlaceKey', () => { @@ -190,6 +496,9 @@ describe('toPublicMapPlace', () => { techProvider: 'DFX.swiss', id: 'id-1', createdAt: '2026-09-26T00:00:00.000Z', + country: null, + shopName: null, + supports: [], }; expect(toPublicMapPlace(stored)).toEqual({ id: 'id-1', @@ -199,6 +508,37 @@ describe('toPublicMapPlace', () => { lon: 8.54, category: 'groceries', techProvider: 'DFX.swiss', + country: null, + shopName: null, + supports: [], + }); + }); + + it('returns country, shop name, and a copy of supports', () => { + const supports = [{ blockchain: 'Ethereum', asset: 'ZCHF' }]; + const stored: StoredMapPlace = { + ...valid, + techProvider: 'DFX.swiss', + id: 'id-1', + createdAt: '2026-09-26T00:00:00.000Z', + country: 'CH', + shopName: 'SPAR', + supports, + }; + const publicPlace = toPublicMapPlace(stored); + expect(publicPlace).toEqual({ + id: 'id-1', + origin: 'dfx', + name: 'SPAR', + lat: 47.37, + lon: 8.54, + category: 'groceries', + techProvider: 'DFX.swiss', + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], }); + publicPlace.supports.push({ blockchain: 'Polygon', asset: 'ETH' }); + expect(stored.supports).toEqual([{ blockchain: 'Ethereum', asset: 'ZCHF' }]); }); }); diff --git a/src/__tests__/map/routes.test.ts b/src/__tests__/map/routes.test.ts index f0b74c881..68fa3858c 100644 --- a/src/__tests__/map/routes.test.ts +++ b/src/__tests__/map/routes.test.ts @@ -39,6 +39,51 @@ describe('map routes', () => { expect(await list.json()).toEqual({ places: [] }); const one = await api.request('/map/places?limit=1'); expect(one.status).toBe(200); + const filters = await api.request('/map/filters'); + expect(filters.status).toBe(200); + expect(await filters.json()).toEqual({ + shopNames: ['SPAR', 'others'], + countries: [], + blockchains: [], + assets: [], + }); + }); + + it('filters GET /map/places by origin and rejects SPAR', async () => { + const api = app({ token: 'secret' }); + const dfx = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify(body), + }); + expect(dfx.status).toBe(201); + const spar = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + ...body, + origin: 'spar', + externalId: 'spar-1', + name: 'SPAR Shop', + }), + }); + expect(spar.status).toBe(201); + const filtered = await api.request('/map/places?origin=spar'); + expect(filtered.status).toBe(200); + const filteredJson = (await filtered.json()) as { + places: Array<{ origin: string; name: string }>; + }; + expect(filteredJson.places).toHaveLength(1); + expect(filteredJson.places[0]?.origin).toBe('spar'); + expect(filteredJson.places[0]?.name).toBe('SPAR Shop'); + const bad = await api.request('/map/places?origin=SPAR'); + expect(bad.status).toBe(400); + expect(await bad.json()).toEqual({ error: 'Place origin is invalid' }); + const all = (await (await api.request('/map/places')).json()) as { + places: Array<{ origin: string }>; + }; + expect(all.places).toHaveLength(2); + expect(all.places.map((row) => row.origin).sort()).toEqual(['dfx', 'spar']); }); it('rejects a bad limit', async () => { @@ -195,6 +240,9 @@ describe('map routes', () => { lon: 8.54, category: 'groceries', techProvider: 'DFX.swiss', + country: null, + shopName: null, + supports: [], }); }); @@ -240,6 +288,9 @@ describe('map routes', () => { lon: 8.54, category: 'groceries', techProvider: '21.gifts', + country: null, + shopName: null, + supports: [], }); }); @@ -279,4 +330,136 @@ describe('map routes', () => { expect(await missing.json()).toEqual({ deleted: false }); expect(calls).toHaveLength(1); }); + + it('filters GET /map/places by shop name, country, blockchain, and asset', async () => { + const api = app({ token: 'secret' }); + const match = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + ...body, + externalId: 'match', + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }), + }); + expect(match.status).toBe(201); + const other = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + ...body, + externalId: 'other', + country: 'DE', + shopName: 'Migros', + supports: [ + { blockchain: 'Polygon', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + ], + }), + }); + expect(other.status).toBe(201); + const filtered = await api.request( + '/map/places?shopName=SPAR&country=CH&blockchain=Ethereum&asset=ZCHF', + ); + expect(filtered.status).toBe(200); + const filteredJson = (await filtered.json()) as { + places: Array<{ name: string; country: string | null; shopName: string | null }>; + }; + expect(filteredJson.places).toHaveLength(1); + expect(filteredJson.places[0]?.shopName).toBe('SPAR'); + expect(filteredJson.places[0]?.country).toBe('CH'); + const others = await api.request('/map/places?shopName=others'); + const othersJson = (await others.json()) as { places: Array<{ shopName: string | null }> }; + expect(othersJson.places).toHaveLength(1); + expect(othersJson.places[0]?.shopName).toBe('Migros'); + expect((await api.request('/map/places?shopName=Migros')).status).toBe(400); + expect(await (await api.request('/map/places?shopName=Migros')).json()).toEqual({ + error: 'Place shop name is invalid', + }); + expect((await api.request('/map/places?country=ZZ')).status).toBe(400); + expect(await (await api.request('/map/places?country=ZZ')).json()).toEqual({ + error: 'Place country is invalid', + }); + expect((await api.request('/map/places?country=CHE')).status).toBe(400); + expect(await (await api.request('/map/places?country=CHE')).json()).toEqual({ + error: 'Place country is invalid', + }); + expect((await api.request('/map/places?country=PH')).status).toBe(200); + expect((await api.request('/map/places?blockchain=ethereum')).status).toBe(400); + expect(await (await api.request('/map/places?blockchain=ethereum')).json()).toEqual({ + error: 'Place support is invalid', + }); + expect((await api.request('/map/places?blockchain=Frick')).status).toBe(400); + expect(await (await api.request('/map/places?blockchain=Frick')).json()).toEqual({ + error: 'Place support is invalid', + }); + expect((await api.request('/map/places?asset=zchf')).status).toBe(400); + expect(await (await api.request('/map/places?asset=zchf')).json()).toEqual({ + error: 'Place support is invalid', + }); + expect((await api.request('/map/places?asset=Ethereum/ZCHF')).status).toBe(400); + expect(await (await api.request('/map/places?asset=Ethereum/ZCHF')).json()).toEqual({ + error: 'Place support is invalid', + }); + }); + + it('returns a stored dEURO asset unchanged', async () => { + const api = app({ token: 'secret' }); + const created = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + ...body, + externalId: 'deuro', + supports: [{ blockchain: 'Ethereum', asset: 'dEURO' }], + }), + }); + expect(created.status).toBe(201); + const listed = await api.request('/map/places?asset=dEURO'); + expect(listed.status).toBe(200); + const json = (await listed.json()) as { + places: Array<{ supports: Array<{ blockchain: string; asset: string }> }>; + }; + expect(json.places).toHaveLength(1); + expect(json.places[0]?.supports).toEqual([{ blockchain: 'Ethereum', asset: 'dEURO' }]); + }); + + it('returns GET /map/filters from stored rows and rejects a bad blockchain', async () => { + const api = app({ token: 'secret' }); + const created = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + ...body, + country: 'CH', + shopName: 'SPAR', + supports: [ + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Polygon', asset: 'ETH' }, + ], + }), + }); + expect(created.status).toBe(201); + const filters = await api.request('/map/filters'); + expect(filters.status).toBe(200); + expect(await filters.json()).toEqual({ + shopNames: ['SPAR', 'others'], + countries: ['CH'], + blockchains: ['Ethereum', 'Polygon'], + assets: ['ETH', 'ZCHF'], + }); + const ethereum = await api.request('/map/filters?blockchain=Ethereum'); + expect(ethereum.status).toBe(200); + expect(await ethereum.json()).toEqual({ + shopNames: ['SPAR', 'others'], + countries: ['CH'], + blockchains: ['Ethereum', 'Polygon'], + assets: ['ZCHF'], + }); + const bad = await api.request('/map/filters?blockchain=ethereum'); + expect(bad.status).toBe(400); + expect(await bad.json()).toEqual({ error: 'Place support is invalid' }); + }); }); diff --git a/src/__tests__/map/store.test.ts b/src/__tests__/map/store.test.ts index be536e220..cc6d20732 100644 --- a/src/__tests__/map/store.test.ts +++ b/src/__tests__/map/store.test.ts @@ -34,6 +34,9 @@ describe('MemoryMapPlaceStore', () => { const first = store.insertIfNew(input); expect(first.created).toBe(true); expect(first.place.techProvider).toBe('DFX.swiss'); + expect(first.place.country).toBeNull(); + expect(first.place.shopName).toBeNull(); + expect(first.place.supports).toEqual([]); const second = store.insertIfNew({ ...input, name: 'Other', lat: 1 }); expect(second.created).toBe(false); expect(second.place.name).toBe('SPAR'); @@ -42,6 +45,9 @@ describe('MemoryMapPlaceStore', () => { const listed = store.list(10); expect(listed.map((row) => row.externalId)).toEqual(['store-2', 'store-1']); expect(store.list(1)).toHaveLength(1); + expect(store.list(10, {}).map((row) => row.externalId)).toEqual(['store-2', 'store-1']); + expect(store.list(10, { blockchain: 'Ethereum' })).toHaveLength(0); + expect(store.list(10, { asset: 'ZCHF' })).toHaveLength(0); store.close(); expect(store.list(10)).toHaveLength(0); expect(later.created).toBe(true); @@ -95,6 +101,8 @@ describe('MemoryMapPlaceStore', () => { lon: 2, category: 'cafe', paymentMethods: 'onchain', + country: 'DE', + shopName: 'Migros', }); expect(updated.created).toBe(false); expect(updated.place.id).toBe(created.place.id); @@ -105,6 +113,8 @@ describe('MemoryMapPlaceStore', () => { expect(updated.place.category).toBe('cafe'); expect(updated.place.paymentMethods).toBe('onchain'); expect(updated.place.techProvider).toBe('DFX.swiss'); + expect(updated.place.country).toBe('DE'); + expect(updated.place.shopName).toBe('Migros'); }); it('keeps techProvider on upsert when omitted and changes it when sent', () => { @@ -118,12 +128,223 @@ describe('MemoryMapPlaceStore', () => { expect(changed.place.techProvider).toBe('DFX.swiss'); }); + it('applies the limit after filtering by origin', () => { + let tick = 0; + const store = new MemoryMapPlaceStore(() => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++))); + store.insertIfNew({ ...input, origin: 'spar', externalId: 'spar-1' }); + store.insertIfNew({ ...input, origin: 'dfx', externalId: 'dfx-1' }); + const listed = store.list(1, { origin: 'spar' }); + expect(listed).toHaveLength(1); + expect(listed[0]?.origin).toBe('spar'); + expect(listed[0]?.externalId).toBe('spar-1'); + }); + + it('returns the older CH pin when a newer pin has another country', () => { + let tick = 0; + const store = new MemoryMapPlaceStore(() => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++))); + store.insertIfNew({ ...input, externalId: 'ch-old', country: 'CH' }); + store.insertIfNew({ ...input, externalId: 'de-new', country: 'DE' }); + expect(store.list(1).map((row) => row.externalId)).toEqual(['de-new']); + const listed = store.list(1, { country: 'CH' }); + expect(listed).toHaveLength(1); + expect(listed[0]?.externalId).toBe('ch-old'); + expect(listed[0]?.country).toBe('CH'); + }); + + it('lists SPAR shop names and others without null brands', () => { + let tick = 0; + const store = new MemoryMapPlaceStore(() => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++))); + store.insertIfNew({ ...input, externalId: 'spar-brand', shopName: 'SPAR' }); + store.insertIfNew({ ...input, externalId: 'migros', shopName: 'Migros' }); + store.insertIfNew({ ...input, externalId: 'none' }); + const spar = store.list(10, { shopName: 'SPAR' }); + expect(spar.map((row) => row.externalId)).toEqual(['spar-brand']); + const others = store.list(10, { shopName: 'others' }); + expect(others.map((row) => row.externalId)).toEqual(['migros']); + expect(others.every((row) => row.shopName !== null && row.shopName !== 'SPAR')).toBe(true); + }); + + it('requires blockchain and asset to match the same support row', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.insertIfNew({ + ...input, + supports: [ + { blockchain: 'Polygon', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + ], + }); + expect(store.list(10, { blockchain: 'Ethereum', asset: 'ZCHF' })).toHaveLength(0); + expect(store.list(10, { blockchain: 'Bitcoin' })).toHaveLength(0); + expect(store.list(10, { asset: 'BTC' })).toHaveLength(0); + expect(store.list(10, { blockchain: 'Ethereum' }).map((row) => row.externalId)).toEqual([ + 'store-1', + ]); + expect(store.list(10, { asset: 'ZCHF' }).map((row) => row.externalId)).toEqual(['store-1']); + expect( + store.list(10, { blockchain: 'Polygon', asset: 'ZCHF' }).map((row) => row.externalId), + ).toEqual(['store-1']); + expect( + store.list(10, { blockchain: 'Ethereum', asset: 'ETH' }).map((row) => row.externalId), + ).toEqual(['store-1']); + }); + + it('sorts supports and copies them so a caller cannot mutate the store', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + const created = store.insertIfNew({ + ...input, + supports: [ + { blockchain: 'Polygon', asset: 'USDT' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + ], + }); + expect(created.place.supports).toEqual([ + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Polygon', asset: 'USDT' }, + ]); + created.place.supports.push({ blockchain: 'Bitcoin', asset: 'BTC' }); + expect(store.list(1)[0]?.supports).toEqual([ + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Polygon', asset: 'USDT' }, + ]); + }); + + it('does not replace country, shop name, or supports on a repeated insert', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.insertIfNew({ + ...input, + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }); + const again = store.insertIfNew({ + ...input, + name: 'Other', + country: 'DE', + shopName: 'Migros', + supports: [{ blockchain: 'Bitcoin', asset: 'BTC' }], + }); + expect(again.created).toBe(false); + expect(again.place.name).toBe('SPAR'); + expect(again.place.country).toBe('CH'); + expect(again.place.shopName).toBe('SPAR'); + expect(again.place.supports).toEqual([{ blockchain: 'Ethereum', asset: 'ZCHF' }]); + }); + + it('sets omitted country and shop name to null on upsert and leaves supports', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.upsert({ + ...input, + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }); + const omitted = store.upsert({ ...input, name: 'Other' }); + expect(omitted.place.name).toBe('Other'); + expect(omitted.place.country).toBeNull(); + expect(omitted.place.shopName).toBeNull(); + expect(omitted.place.supports).toEqual([{ blockchain: 'Ethereum', asset: 'ZCHF' }]); + }); + + it('clears supports on upsert when the body sends an empty list', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.upsert({ + ...input, + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }); + const emptied = store.upsert({ + ...input, + country: 'DE', + shopName: 'Migros', + supports: [], + }); + expect(emptied.place.country).toBe('DE'); + expect(emptied.place.shopName).toBe('Migros'); + expect(emptied.place.supports).toEqual([]); + }); + + it('returns distinct sorted filter values and restricts assets by blockchain', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + expect(store.filters()).toEqual({ countries: [], blockchains: [], assets: [] }); + store.insertIfNew({ + ...input, + country: 'CH', + shopName: 'SPAR', + supports: [ + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Polygon', asset: 'USDT' }, + ], + }); + store.insertIfNew({ + ...input, + externalId: 'store-2', + country: 'DE', + supports: [{ blockchain: 'Bitcoin', asset: 'BTC' }], + }); + store.insertIfNew({ ...input, externalId: 'none' }); + expect(store.filters()).toEqual({ + countries: ['CH', 'DE'], + blockchains: ['Bitcoin', 'Ethereum', 'Polygon'], + assets: ['BTC', 'USDT', 'ZCHF'], + }); + expect(store.filters('Ethereum')).toEqual({ + countries: ['CH', 'DE'], + blockchains: ['Bitcoin', 'Ethereum', 'Polygon'], + assets: ['ZCHF'], + }); + expect(store.filters('Sepolia')).toEqual({ + countries: ['CH', 'DE'], + blockchains: ['Bitcoin', 'Ethereum', 'Polygon'], + assets: [], + }); + }); + + it('applies AND predicates before the limit', () => { + let tick = 0; + const store = new MemoryMapPlaceStore(() => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++))); + store.insertIfNew({ + ...input, + origin: 'spar', + externalId: 'match', + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }); + store.insertIfNew({ + ...input, + origin: 'spar', + externalId: 'miss', + country: 'DE', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }); + const listed = store.list(1, { + origin: 'spar', + country: 'CH', + shopName: 'SPAR', + blockchain: 'Ethereum', + asset: 'ZCHF', + }); + expect(listed).toHaveLength(1); + expect(listed[0]?.externalId).toBe('match'); + }); + it('deletes by key and reports a miss', () => { const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); expect(store.deleteByKey('dfx', 'store-1')).toBe(false); - store.insertIfNew(input); + store.insertIfNew({ + ...input, + country: 'CH', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }); expect(store.deleteByKey('dfx', 'store-1')).toBe(true); expect(store.list(10)).toHaveLength(0); + expect(store.filters()).toEqual({ countries: [], blockchains: [], assets: [] }); expect(store.deleteByKey('dfx', 'store-1')).toBe(false); }); }); diff --git a/src/lib/map/place.ts b/src/lib/map/place.ts index bedb7d14f..253cef245 100644 --- a/src/lib/map/place.ts +++ b/src/lib/map/place.ts @@ -3,6 +3,11 @@ * Coordinates are rounded to six decimal places. */ +export type MapPlaceSupport = { + blockchain: string; + asset: string; +}; + export type MapPlaceInput = { origin: string; externalId: string; @@ -12,12 +17,21 @@ export type MapPlaceInput = { category: string; paymentMethods: string | null; techProvider?: string; + country?: string; + shopName?: string; + supports?: MapPlaceSupport[]; }; -export type StoredMapPlace = MapPlaceInput & { +export type StoredMapPlace = Omit< + MapPlaceInput, + 'techProvider' | 'country' | 'shopName' | 'supports' +> & { id: string; createdAt: string; techProvider: string; + country: string | null; + shopName: string | null; + supports: MapPlaceSupport[]; }; /** Fields the public map may show. The caller's own id stays off this list. */ @@ -29,6 +43,17 @@ export type PublicMapPlace = { lon: number; category: string; techProvider: string; + country: string | null; + shopName: string | null; + supports: MapPlaceSupport[]; +}; + +/** Query fields that restrict GET /map/places, all optional. */ +export type MapPlaceQueryFilter = { + country?: string; + shopName?: 'SPAR' | 'others'; + blockchain?: string; + asset?: string; }; const COORD_ERROR = 'Place must be a latitude and longitude'; @@ -37,9 +62,297 @@ const EXTERNAL_ID_ERROR = 'Place external id is required'; const NAME_ERROR = 'Place name is required'; const CATEGORY_ERROR = 'Place category is required'; const TECH_PROVIDER_ERROR = 'Place tech provider is invalid'; +const COUNTRY_ERROR = 'Place country is invalid'; +const SHOP_NAME_ERROR = 'Place shop name is invalid'; +const SUPPORT_ERROR = 'Place support is invalid'; const PAYMENT_METHODS = /^(onchain|lightning|nfc)(,(onchain|lightning|nfc))*$/; const TECH_PROVIDER = /^[A-Za-z0-9][A-Za-z0-9.-]{0,39}$/; +const ASSET_TICKER = /^[A-Z][A-Z0-9]{1,31}$/; +const ASSET_PREFIXED = /^[a-z][A-Z][A-Z0-9]{1,30}$/; + +const ISO_COUNTRIES: ReadonlySet = new Set([ + 'AD', + 'AE', + 'AF', + 'AG', + 'AI', + 'AL', + 'AM', + 'AO', + 'AQ', + 'AR', + 'AS', + 'AT', + 'AU', + 'AW', + 'AX', + 'AZ', + 'BA', + 'BB', + 'BD', + 'BE', + 'BF', + 'BG', + 'BH', + 'BI', + 'BJ', + 'BL', + 'BM', + 'BN', + 'BO', + 'BQ', + 'BR', + 'BS', + 'BT', + 'BV', + 'BW', + 'BY', + 'BZ', + 'CA', + 'CC', + 'CD', + 'CF', + 'CG', + 'CH', + 'CI', + 'CK', + 'CL', + 'CM', + 'CN', + 'CO', + 'CR', + 'CU', + 'CV', + 'CW', + 'CX', + 'CY', + 'CZ', + 'DE', + 'DJ', + 'DK', + 'DM', + 'DO', + 'DZ', + 'EC', + 'EE', + 'EG', + 'EH', + 'ER', + 'ES', + 'ET', + 'FI', + 'FJ', + 'FK', + 'FM', + 'FO', + 'FR', + 'GA', + 'GB', + 'GD', + 'GE', + 'GF', + 'GG', + 'GH', + 'GI', + 'GL', + 'GM', + 'GN', + 'GP', + 'GQ', + 'GR', + 'GS', + 'GT', + 'GU', + 'GW', + 'GY', + 'HK', + 'HM', + 'HN', + 'HR', + 'HT', + 'HU', + 'ID', + 'IE', + 'IL', + 'IM', + 'IN', + 'IO', + 'IQ', + 'IR', + 'IS', + 'IT', + 'JE', + 'JM', + 'JO', + 'JP', + 'KE', + 'KG', + 'KH', + 'KI', + 'KM', + 'KN', + 'KP', + 'KR', + 'KW', + 'KY', + 'KZ', + 'LA', + 'LB', + 'LC', + 'LI', + 'LK', + 'LR', + 'LS', + 'LT', + 'LU', + 'LV', + 'LY', + 'MA', + 'MC', + 'MD', + 'ME', + 'MF', + 'MG', + 'MH', + 'MK', + 'ML', + 'MM', + 'MN', + 'MO', + 'MP', + 'MQ', + 'MR', + 'MS', + 'MT', + 'MU', + 'MV', + 'MW', + 'MX', + 'MY', + 'MZ', + 'NA', + 'NC', + 'NE', + 'NF', + 'NG', + 'NI', + 'NL', + 'NO', + 'NP', + 'NR', + 'NU', + 'NZ', + 'OM', + 'PA', + 'PE', + 'PF', + 'PG', + 'PH', + 'PK', + 'PL', + 'PM', + 'PN', + 'PR', + 'PS', + 'PT', + 'PW', + 'PY', + 'QA', + 'RE', + 'RO', + 'RS', + 'RU', + 'RW', + 'SA', + 'SB', + 'SC', + 'SD', + 'SE', + 'SG', + 'SH', + 'SI', + 'SJ', + 'SK', + 'SL', + 'SM', + 'SN', + 'SO', + 'SR', + 'SS', + 'ST', + 'SV', + 'SX', + 'SY', + 'SZ', + 'TC', + 'TD', + 'TF', + 'TG', + 'TH', + 'TJ', + 'TK', + 'TL', + 'TM', + 'TN', + 'TO', + 'TR', + 'TT', + 'TV', + 'TW', + 'TZ', + 'UA', + 'UG', + 'UM', + 'US', + 'UY', + 'UZ', + 'VA', + 'VC', + 'VE', + 'VG', + 'VI', + 'VN', + 'VU', + 'WF', + 'WS', + 'YE', + 'YT', + 'ZA', + 'ZM', + 'ZW', +]); + +const PAYMENT_BLOCKCHAINS: ReadonlySet = new Set([ + 'Bitcoin', + 'Lightning', + 'Spark', + 'Arkade', + 'Firo', + 'Monero', + 'Zano', + 'Ethereum', + 'Sepolia', + 'BinanceSmartChain', + 'Optimism', + 'Arbitrum', + 'Polygon', + 'Base', + 'Haqq', + 'Liquid', + 'Arweave', + 'Cardano', + 'InternetComputer', + 'DeFiChain', + 'Railgun', + 'Solana', + 'Gnosis', + 'Plasma', + 'Tron', + 'Citrea', + 'CitreaTestnet', + 'BitcoinTestnet4', +]); function roundCoord(n: number): number { const rounded = Math.round(n * 1e6) / 1e6; @@ -56,7 +369,46 @@ function hasNoControls(value: string): boolean { return true; } -function normalizeOrigin(raw: unknown): { ok: true; value: string } | { ok: false; error: string } { +function compareSupport(a: MapPlaceSupport, b: MapPlaceSupport): number { + if (a.blockchain < b.blockchain) { + return -1; + } + if (a.blockchain > b.blockchain) { + return 1; + } + if (a.asset < b.asset) { + return -1; + } + if (a.asset > b.asset) { + return 1; + } + return 0; +} + +function uniqueSortedSupports(items: MapPlaceSupport[]): MapPlaceSupport[] { + const sorted = [...items] + .map((item) => ({ blockchain: item.blockchain, asset: item.asset })) + .sort(compareSupport); + const unique: MapPlaceSupport[] = []; + for (const item of sorted) { + const prev = unique[unique.length - 1]; + if (prev !== undefined && prev.blockchain === item.blockchain && prev.asset === item.asset) { + continue; + } + unique.push(item); + } + return unique; +} + +/** + * Validate a pin origin. + * + * @param raw - Query string or JSON value. + * @returns The trimmed origin, or a fixed error message. + */ +export function normalizePlaceOrigin( + raw: unknown, +): { ok: true; value: string } | { ok: false; error: string } { if (typeof raw !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(raw.trim())) { return { ok: false, error: ORIGIN_ERROR }; } @@ -95,6 +447,94 @@ function normalizeTechProvider( return { ok: true, value: trimmed }; } +function normalizeCountry( + raw: unknown, +): { ok: true; value: string } | { ok: false; error: string } { + if (typeof raw !== 'string') { + return { ok: false, error: COUNTRY_ERROR }; + } + const country = raw.trim().toUpperCase(); + if (!ISO_COUNTRIES.has(country)) { + return { ok: false, error: COUNTRY_ERROR }; + } + return { ok: true, value: country }; +} + +function normalizeShopName( + raw: unknown, +): { ok: true; value: string } | { ok: false; error: string } { + if (typeof raw !== 'string') { + return { ok: false, error: SHOP_NAME_ERROR }; + } + const shopName = raw.trim(); + if (shopName.length < 1 || shopName.length > 40 || !hasNoControls(shopName)) { + return { ok: false, error: SHOP_NAME_ERROR }; + } + return { ok: true, value: shopName }; +} + +function normalizeQueryShopName( + raw: string, +): { ok: true; value: 'SPAR' | 'others' } | { ok: false; error: string } { + const shopName = raw.trim(); + if (shopName !== 'SPAR' && shopName !== 'others') { + return { ok: false, error: SHOP_NAME_ERROR }; + } + return { ok: true, value: shopName }; +} + +function normalizeBlockchain( + raw: unknown, +): { ok: true; value: string } | { ok: false; error: string } { + if (typeof raw !== 'string') { + return { ok: false, error: SUPPORT_ERROR }; + } + const blockchain = raw.trim(); + if (!PAYMENT_BLOCKCHAINS.has(blockchain)) { + return { ok: false, error: SUPPORT_ERROR }; + } + return { ok: true, value: blockchain }; +} + +function normalizeAsset(raw: unknown): { ok: true; value: string } | { ok: false; error: string } { + if (typeof raw !== 'string') { + return { ok: false, error: SUPPORT_ERROR }; + } + const asset = raw.trim(); + if (!ASSET_TICKER.test(asset) && !ASSET_PREFIXED.test(asset)) { + return { ok: false, error: SUPPORT_ERROR }; + } + return { ok: true, value: asset }; +} + +function normalizeSupports( + raw: unknown, +): { ok: true; value: MapPlaceSupport[] } | { ok: false; error: string } { + if (!Array.isArray(raw)) { + return { ok: false, error: SUPPORT_ERROR }; + } + if (raw.length > 32) { + return { ok: false, error: SUPPORT_ERROR }; + } + const items: MapPlaceSupport[] = []; + for (const item of raw) { + if (typeof item !== 'object' || item === null || Array.isArray(item)) { + return { ok: false, error: SUPPORT_ERROR }; + } + const rec = item as Record; + const blockchain = normalizeBlockchain(rec['blockchain']); + if (!blockchain.ok) { + return blockchain; + } + const asset = normalizeAsset(rec['asset']); + if (!asset.ok) { + return asset; + } + items.push({ blockchain: blockchain.value, asset: asset.value }); + } + return { ok: true, value: uniqueSortedSupports(items) }; +} + /** * Validate a JSON body for one map pin. * @@ -123,7 +563,7 @@ export function normalizeMapPlace( return { ok: false, error: COORD_ERROR }; } - const origin = normalizeOrigin(rec['origin']); + const origin = normalizePlaceOrigin(rec['origin']); if (!origin.ok) { return origin; } @@ -165,6 +605,36 @@ export function normalizeMapPlace( return techProvider; } + const rawCountry = rec['country']; + let country: string | undefined; + if (rawCountry !== undefined && rawCountry !== null) { + const parsedCountry = normalizeCountry(rawCountry); + if (!parsedCountry.ok) { + return parsedCountry; + } + country = parsedCountry.value; + } + + const rawShopName = rec['shopName']; + let shopName: string | undefined; + if (rawShopName !== undefined && rawShopName !== null) { + const parsedShopName = normalizeShopName(rawShopName); + if (!parsedShopName.ok) { + return parsedShopName; + } + shopName = parsedShopName.value; + } + + const rawSupports = rec['supports']; + let supports: MapPlaceSupport[] | undefined; + if (rawSupports !== undefined) { + const parsedSupports = normalizeSupports(rawSupports); + if (!parsedSupports.ok) { + return parsedSupports; + } + supports = parsedSupports.value; + } + const value: MapPlaceInput = { origin: origin.value, externalId: externalId.value, @@ -177,6 +647,15 @@ export function normalizeMapPlace( if (techProvider.value !== undefined) { value.techProvider = techProvider.value; } + if (country !== undefined) { + value.country = country; + } + if (shopName !== undefined) { + value.shopName = shopName; + } + if (supports !== undefined) { + value.supports = supports; + } return { ok: true, value }; } @@ -193,7 +672,7 @@ export function normalizeMapPlaceKey( return { ok: false, error: ORIGIN_ERROR }; } const rec = input as Record; - const origin = normalizeOrigin(rec['origin']); + const origin = normalizePlaceOrigin(rec['origin']); if (!origin.ok) { return origin; } @@ -204,6 +683,53 @@ export function normalizeMapPlaceKey( return { ok: true, value: { origin: origin.value, externalId: externalId.value } }; } +/** + * Validate optional public map query fields. + * + * @param country - Optional ISO country query. + * @param shopName - Optional SPAR or others query. + * @param blockchain - Optional payment network query. + * @param asset - Optional asset name query. + * @returns The present filter fields, or a fixed error message. + */ +export function normalizeMapPlaceFilter( + country?: string | undefined, + shopName?: string | undefined, + blockchain?: string | undefined, + asset?: string | undefined, +): { ok: true; value: MapPlaceQueryFilter } | { ok: false; error: string } { + const value: MapPlaceQueryFilter = {}; + if (country !== undefined) { + const parsed = normalizeCountry(country); + if (!parsed.ok) { + return parsed; + } + value.country = parsed.value; + } + if (shopName !== undefined) { + const parsed = normalizeQueryShopName(shopName); + if (!parsed.ok) { + return parsed; + } + value.shopName = parsed.value; + } + if (blockchain !== undefined) { + const parsed = normalizeBlockchain(blockchain); + if (!parsed.ok) { + return parsed; + } + value.blockchain = parsed.value; + } + if (asset !== undefined) { + const parsed = normalizeAsset(asset); + if (!parsed.ok) { + return parsed; + } + value.asset = parsed.value; + } + return { ok: true, value }; +} + /** * Drop fields that only the ingest caller needs. * @@ -219,5 +745,11 @@ export function toPublicMapPlace(place: StoredMapPlace): PublicMapPlace { lon: place.lon, category: place.category, techProvider: place.techProvider, + country: place.country, + shopName: place.shopName, + supports: place.supports.map((item) => ({ + blockchain: item.blockchain, + asset: item.asset, + })), }; } diff --git a/src/lib/map/sqlite.ts b/src/lib/map/sqlite.ts index 02d86841e..c63445085 100644 --- a/src/lib/map/sqlite.ts +++ b/src/lib/map/sqlite.ts @@ -1,9 +1,9 @@ import { Database } from 'bun:sqlite'; -import type { MapPlaceInput, StoredMapPlace } from '@/lib/map/place'; -import { MAP_PLACE_SCHEMA_SQL, type MapPlaceStore } from '@/lib/map/store'; +import type { MapPlaceInput, MapPlaceSupport, StoredMapPlace } from '@/lib/map/place'; +import { MAP_PLACE_SCHEMA_SQL, type MapPlaceListFilter, type MapPlaceStore } from '@/lib/map/store'; const MAP_PLACE_COLUMNS = - 'id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider'; + 'id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider, country, shop_name'; type MapPlaceRow = { id: string; @@ -16,9 +16,42 @@ type MapPlaceRow = { payment_methods: string | null; created_at: string; tech_provider: string; + country: string | null; + shop_name: string | null; }; -function mapRow(row: MapPlaceRow): StoredMapPlace { +function compareSupport(a: MapPlaceSupport, b: MapPlaceSupport): number { + if (a.blockchain < b.blockchain) { + return -1; + } + if (a.blockchain > b.blockchain) { + return 1; + } + if (a.asset < b.asset) { + return -1; + } + if (a.asset > b.asset) { + return 1; + } + return 0; +} + +function storedSupports(items: MapPlaceSupport[]): MapPlaceSupport[] { + const sorted = [...items] + .map((item) => ({ blockchain: item.blockchain, asset: item.asset })) + .sort(compareSupport); + const unique: MapPlaceSupport[] = []; + for (const item of sorted) { + const prev = unique[unique.length - 1]; + if (prev !== undefined && prev.blockchain === item.blockchain && prev.asset === item.asset) { + continue; + } + unique.push(item); + } + return unique; +} + +function mapRow(row: MapPlaceRow, supports: MapPlaceSupport[]): StoredMapPlace { return { id: row.id, origin: row.origin, @@ -30,9 +63,76 @@ function mapRow(row: MapPlaceRow): StoredMapPlace { paymentMethods: row.payment_methods, createdAt: row.created_at, techProvider: row.tech_provider, + country: row.country, + shopName: row.shop_name, + supports: supports.map((item) => ({ blockchain: item.blockchain, asset: item.asset })), }; } +function loadSupportsByPlaceId(db: Database, ids: string[]): Map { + const supportsById = new Map(); + for (const id of ids) { + supportsById.set(id, []); + } + if (ids.length === 0) { + return supportsById; + } + const placeholders = ids.map(() => '?').join(', '); + const rows = db + .query( + `SELECT place_id, blockchain, asset + FROM map_place_support + WHERE place_id IN (${placeholders}) + ORDER BY blockchain, asset`, + ) + .all(...ids) as Array<{ place_id: string; blockchain: string; asset: string }>; + for (const row of rows) { + const list = supportsById.get(row.place_id); + if (list === undefined) { + throw new Error('map place support for unknown id'); + } + list.push({ blockchain: row.blockchain, asset: row.asset }); + } + return supportsById; +} + +function mapRows(db: Database, rows: MapPlaceRow[]): StoredMapPlace[] { + const supportsById = loadSupportsByPlaceId( + db, + rows.map((row) => row.id), + ); + return rows.map((row) => { + const supports = supportsById.get(row.id); + if (supports === undefined) { + throw new Error('map place supports missing'); + } + return mapRow(row, supports); + }); +} + +function mapOne(db: Database, row: MapPlaceRow): StoredMapPlace { + const mapped = mapRows(db, [row]); + const place = mapped[0]; + if (place === undefined) { + throw new Error('map place row missing after map'); + } + return place; +} + +function insertSupportRows(db: Database, placeId: string, supports: MapPlaceSupport[]): void { + const insert = db.query( + 'INSERT INTO map_place_support (place_id, blockchain, asset) VALUES (?, ?, ?)', + ); + for (const item of supports) { + insert.run(placeId, item.blockchain, item.asset); + } +} + +function replaceSupportRows(db: Database, placeId: string, supports: MapPlaceSupport[]): void { + db.query('DELETE FROM map_place_support WHERE place_id = ?').run(placeId); + insertSupportRows(db, placeId, supports); +} + /** * SQLite {@link MapPlaceStore}. Opened by the process entrypoint. * Unit tests use {@link MemoryMapPlaceStore}; this driver is exercised by @@ -46,13 +146,32 @@ export class SqliteMapPlaceStore implements MapPlaceStore { */ constructor(filename: string) { this.#db = new Database(filename); - this.#db.exec(MAP_PLACE_SCHEMA_SQL); - const columns = this.#db.query('PRAGMA table_info(map_place)').all() as Array<{ name: string }>; - if (!columns.some((column) => column.name === 'tech_provider')) { - this.#db.exec( - "ALTER TABLE map_place ADD COLUMN tech_provider TEXT NOT NULL DEFAULT 'DFX.swiss'", - ); + this.#db.exec('PRAGMA foreign_keys = ON'); + const statements = MAP_PLACE_SCHEMA_SQL.split(';') + .map((part) => part.trim()) + .filter((part) => part !== ''); + for (const [index, sql] of statements.entries()) { + this.#db.exec(sql); + if (index === 0) { + const columns = this.#db.query('PRAGMA table_info(map_place)').all() as Array<{ + name: string; + }>; + if (!columns.some((column) => column.name === 'tech_provider')) { + this.#db.exec( + "ALTER TABLE map_place ADD COLUMN tech_provider TEXT NOT NULL DEFAULT 'DFX.swiss'", + ); + } + if (!columns.some((column) => column.name === 'country')) { + this.#db.exec('ALTER TABLE map_place ADD COLUMN country TEXT'); + } + if (!columns.some((column) => column.name === 'shop_name')) { + this.#db.exec('ALTER TABLE map_place ADD COLUMN shop_name TEXT'); + } + } } + this.#db.exec( + "UPDATE map_place SET shop_name = 'SPAR' WHERE origin = 'spar' AND shop_name IS NULL", + ); } /** @@ -65,11 +184,14 @@ export class SqliteMapPlaceStore implements MapPlaceStore { const id = crypto.randomUUID(); const createdAt = new Date().toISOString(); const techProvider = input.techProvider ?? 'DFX.swiss'; + const country = input.country ?? null; + const shopName = input.shopName ?? null; + const supports = storedSupports(input.supports ?? []); const result = this.#db .query( `INSERT INTO map_place ( - id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider, country, shop_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (origin, external_id) DO NOTHING`, ) .run( @@ -83,9 +205,32 @@ export class SqliteMapPlaceStore implements MapPlaceStore { input.paymentMethods, createdAt, techProvider, + country, + shopName, ); if (result.changes === 1) { - return { created: true, place: { ...input, techProvider, id, createdAt } }; + insertSupportRows(this.#db, id, supports); + return { + created: true, + place: { + origin: input.origin, + externalId: input.externalId, + name: input.name, + lat: input.lat, + lon: input.lon, + category: input.category, + paymentMethods: input.paymentMethods, + techProvider, + country, + shopName, + supports: supports.map((item) => ({ + blockchain: item.blockchain, + asset: item.asset, + })), + id, + createdAt, + }, + }; } const existing = this.#db .query( @@ -96,7 +241,7 @@ export class SqliteMapPlaceStore implements MapPlaceStore { if (existing === null) { throw new Error('map place insert conflict missing row'); } - return { created: false, place: mapRow(existing) }; + return { created: false, place: mapOne(this.#db, existing) }; } /** @@ -117,10 +262,12 @@ export class SqliteMapPlaceStore implements MapPlaceStore { } const techProvider = input.techProvider !== undefined ? input.techProvider : existing.tech_provider; + const country = input.country ?? null; + const shopName = input.shopName ?? null; this.#db .query( `UPDATE map_place - SET name = ?, lat = ?, lon = ?, category = ?, payment_methods = ?, tech_provider = ? + SET name = ?, lat = ?, lon = ?, category = ?, payment_methods = ?, tech_provider = ?, country = ?, shop_name = ? WHERE origin = ? AND external_id = ?`, ) .run( @@ -130,20 +277,27 @@ export class SqliteMapPlaceStore implements MapPlaceStore { input.category, input.paymentMethods, techProvider, + country, + shopName, input.origin, input.externalId, ); + if (input.supports !== undefined) { + replaceSupportRows(this.#db, existing.id, storedSupports(input.supports)); + } return { created: false, - place: { - ...mapRow(existing), + place: mapOne(this.#db, { + ...existing, name: input.name, lat: input.lat, lon: input.lon, category: input.category, - paymentMethods: input.paymentMethods, - techProvider, - }, + payment_methods: input.paymentMethods, + tech_provider: techProvider, + country, + shop_name: shopName, + }), }; } @@ -162,21 +316,103 @@ export class SqliteMapPlaceStore implements MapPlaceStore { } /** - * Newest pins first. + * Newest pins first, then id descending. * * @param limit - Maximum rows. + * @param filter - Optional AND predicates, applied before sort and limit. * @returns Stored pins. */ - list(limit: number): StoredMapPlace[] { + list(limit: number, filter?: MapPlaceListFilter): StoredMapPlace[] { + const where: string[] = []; + const params: Array = []; + if (filter?.origin !== undefined) { + where.push('origin = ?'); + params.push(filter.origin); + } + if (filter?.country !== undefined) { + where.push('country = ?'); + params.push(filter.country); + } + if (filter?.shopName === 'SPAR') { + where.push('shop_name = ?'); + params.push('SPAR'); + } else if (filter?.shopName === 'others') { + where.push('shop_name IS NOT NULL AND shop_name <> ?'); + params.push('SPAR'); + } + if (filter !== undefined && filter.blockchain !== undefined && filter.asset !== undefined) { + where.push( + 'id IN (SELECT place_id FROM map_place_support WHERE blockchain = ? AND asset = ?)', + ); + params.push(filter.blockchain, filter.asset); + } else if (filter !== undefined && filter.blockchain !== undefined) { + where.push('id IN (SELECT place_id FROM map_place_support WHERE blockchain = ?)'); + params.push(filter.blockchain); + } else if (filter !== undefined && filter.asset !== undefined) { + where.push('id IN (SELECT place_id FROM map_place_support WHERE asset = ?)'); + params.push(filter.asset); + } + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; const rows = this.#db .query( `SELECT ${MAP_PLACE_COLUMNS} FROM map_place + ${whereSql} ORDER BY created_at DESC, id DESC LIMIT ?`, ) - .all(limit) as MapPlaceRow[]; - return rows.map((row) => mapRow(row)); + .all(...params, limit) as MapPlaceRow[]; + return mapRows(this.#db, rows); + } + + /** + * Distinct stored filter values, sorted. + * + * @param blockchain - When set, only assets stored on that network. + * @returns Countries, blockchains, and assets. Shop names are not read. + */ + filters(blockchain?: string | undefined): { + countries: string[]; + blockchains: string[]; + assets: string[]; + } { + const countryRows = this.#db + .query( + `SELECT DISTINCT country + FROM map_place + WHERE country IS NOT NULL + ORDER BY country`, + ) + .all() as Array<{ country: string }>; + const blockchainRows = this.#db + .query( + `SELECT DISTINCT blockchain + FROM map_place_support + ORDER BY blockchain`, + ) + .all() as Array<{ blockchain: string }>; + const assetRows = + blockchain === undefined + ? (this.#db + .query( + `SELECT DISTINCT asset + FROM map_place_support + ORDER BY asset`, + ) + .all() as Array<{ asset: string }>) + : (this.#db + .query( + `SELECT DISTINCT asset + FROM map_place_support + WHERE blockchain = ? + ORDER BY asset`, + ) + .all(blockchain) as Array<{ asset: string }>); + return { + countries: countryRows.map((row) => row.country), + blockchains: blockchainRows.map((row) => row.blockchain), + assets: assetRows.map((row) => row.asset), + }; } /** Close the database. */ diff --git a/src/lib/map/store.ts b/src/lib/map/store.ts index be9944486..7b617cbaa 100644 --- a/src/lib/map/store.ts +++ b/src/lib/map/store.ts @@ -1,4 +1,9 @@ -import type { MapPlaceInput, StoredMapPlace } from '@/lib/map/place'; +import type { + MapPlaceInput, + MapPlaceQueryFilter, + MapPlaceSupport, + StoredMapPlace, +} from '@/lib/map/place'; const DEFAULT_TECH_PROVIDER = 'DFX.swiss'; @@ -14,8 +19,114 @@ export const MAP_PLACE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS map_place ( payment_methods TEXT, created_at TEXT NOT NULL, tech_provider TEXT NOT NULL DEFAULT 'DFX.swiss', + country TEXT, + shop_name TEXT, UNIQUE (origin, external_id) -)`; +); +CREATE TABLE IF NOT EXISTS map_place_support ( + place_id TEXT NOT NULL, + blockchain TEXT NOT NULL, + asset TEXT NOT NULL, + PRIMARY KEY (place_id, blockchain, asset), + FOREIGN KEY (place_id) REFERENCES map_place(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS map_place_country ON map_place(country); +CREATE INDEX IF NOT EXISTS map_place_shop_name ON map_place(shop_name); +CREATE INDEX IF NOT EXISTS map_place_support_lookup + ON map_place_support(blockchain, asset, place_id)`; + +/** Optional AND predicates for `list`, applied before sort and limit. */ +export type MapPlaceListFilter = MapPlaceQueryFilter & { + origin?: string; +}; + +function compareSupport(a: MapPlaceSupport, b: MapPlaceSupport): number { + if (a.blockchain < b.blockchain) { + return -1; + } + if (a.blockchain > b.blockchain) { + return 1; + } + if (a.asset < b.asset) { + return -1; + } + if (a.asset > b.asset) { + return 1; + } + return 0; +} + +function storedSupports(items: MapPlaceSupport[]): MapPlaceSupport[] { + const sorted = [...items] + .map((item) => ({ blockchain: item.blockchain, asset: item.asset })) + .sort(compareSupport); + const unique: MapPlaceSupport[] = []; + for (const item of sorted) { + const prev = unique[unique.length - 1]; + if (prev !== undefined && prev.blockchain === item.blockchain && prev.asset === item.asset) { + continue; + } + unique.push(item); + } + return unique; +} + +function copyStoredPlace(place: StoredMapPlace): StoredMapPlace { + return { + ...place, + supports: place.supports.map((item) => ({ + blockchain: item.blockchain, + asset: item.asset, + })), + }; +} + +function matchesFilter(row: StoredMapPlace, filter?: MapPlaceListFilter): boolean { + if (filter === undefined) { + return true; + } + if (filter.origin !== undefined && row.origin !== filter.origin) { + return false; + } + if (filter.country !== undefined && row.country !== filter.country) { + return false; + } + if (filter.shopName === 'SPAR' && row.shopName !== 'SPAR') { + return false; + } + if (filter.shopName === 'others' && (row.shopName === null || row.shopName === 'SPAR')) { + return false; + } + if (filter.blockchain !== undefined || filter.asset !== undefined) { + const match = row.supports.some( + (item) => + (filter.blockchain === undefined || item.blockchain === filter.blockchain) && + (filter.asset === undefined || item.asset === filter.asset), + ); + if (!match) { + return false; + } + } + return true; +} + +function toStoredPlace(input: MapPlaceInput, id: string, createdAt: string): StoredMapPlace { + return { + origin: input.origin, + externalId: input.externalId, + name: input.name, + lat: input.lat, + lon: input.lon, + category: input.category, + paymentMethods: input.paymentMethods, + techProvider: input.techProvider ?? DEFAULT_TECH_PROVIDER, + country: input.country ?? null, + shopName: input.shopName ?? null, + supports: storedSupports(input.supports ?? []), + id, + createdAt, + }; +} /** * Store for map pins. The SQLite driver is `sqlite.ts`. Tests use the memory store. @@ -24,7 +135,12 @@ export interface MapPlaceStore { insertIfNew(input: MapPlaceInput): { created: boolean; place: StoredMapPlace }; upsert(input: MapPlaceInput): { created: boolean; place: StoredMapPlace }; deleteByKey(origin: string, externalId: string): boolean; - list(limit: number): StoredMapPlace[]; + list(limit: number, filter?: MapPlaceListFilter): StoredMapPlace[]; + filters(blockchain?: string | undefined): { + countries: string[]; + blockchains: string[]; + assets: string[]; + }; close(): void; } @@ -53,16 +169,11 @@ export class MemoryMapPlaceStore implements MapPlaceStore { (row) => row.origin === input.origin && row.externalId === input.externalId, ); if (existing !== undefined) { - return { created: false, place: { ...existing } }; + return { created: false, place: copyStoredPlace(existing) }; } - const place: StoredMapPlace = { - ...input, - techProvider: input.techProvider ?? DEFAULT_TECH_PROVIDER, - id: crypto.randomUUID(), - createdAt: this.#now().toISOString(), - }; + const place = toStoredPlace(input, crypto.randomUUID(), this.#now().toISOString()); this.#rows.push(place); - return { created: true, place: { ...place } }; + return { created: true, place: copyStoredPlace(place) }; } /** @@ -83,10 +194,15 @@ export class MemoryMapPlaceStore implements MapPlaceStore { existing.lon = input.lon; existing.category = input.category; existing.paymentMethods = input.paymentMethods; + existing.country = input.country ?? null; + existing.shopName = input.shopName ?? null; if (input.techProvider !== undefined) { existing.techProvider = input.techProvider; } - return { created: false, place: { ...existing } }; + if (input.supports !== undefined) { + existing.supports = storedSupports(input.supports); + } + return { created: false, place: copyStoredPlace(existing) }; } /** @@ -111,10 +227,12 @@ export class MemoryMapPlaceStore implements MapPlaceStore { * Newest pins first, then id descending. * * @param limit - Maximum rows. + * @param filter - Optional AND predicates, applied before sort and limit. * @returns A copy of the stored pins. */ - list(limit: number): StoredMapPlace[] { - return [...this.#rows] + list(limit: number, filter?: MapPlaceListFilter): StoredMapPlace[] { + const rows = this.#rows.filter((row) => matchesFilter(row, filter)); + return [...rows] .sort((a, b) => { const byTime = b.createdAt.localeCompare(a.createdAt); if (byTime !== 0) { @@ -123,7 +241,39 @@ export class MemoryMapPlaceStore implements MapPlaceStore { return b.id.localeCompare(a.id); }) .slice(0, limit) - .map((row) => ({ ...row })); + .map((row) => copyStoredPlace(row)); + } + + /** + * Distinct stored filter values, sorted. + * + * @param blockchain - When set, only assets stored on that network. + * @returns Countries, blockchains, and assets. Shop names are not read. + */ + filters(blockchain?: string | undefined): { + countries: string[]; + blockchains: string[]; + assets: string[]; + } { + const countrySet = new Set(); + const blockchainSet = new Set(); + const assetSet = new Set(); + for (const row of this.#rows) { + if (row.country !== null) { + countrySet.add(row.country); + } + for (const item of row.supports) { + blockchainSet.add(item.blockchain); + if (blockchain === undefined || item.blockchain === blockchain) { + assetSet.add(item.asset); + } + } + } + return { + countries: [...countrySet].sort(), + blockchains: [...blockchainSet].sort(), + assets: [...assetSet].sort(), + }; } /** Nothing to close. */ diff --git a/src/routes/map.ts b/src/routes/map.ts index 105fa2f2f..5447e5b19 100644 --- a/src/routes/map.ts +++ b/src/routes/map.ts @@ -2,8 +2,14 @@ import { timingSafeEqual } from 'node:crypto'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { pushMapPlace, type FetchLike, type MapPushResult } from '@/lib/map/btcmap'; -import { normalizeMapPlace, normalizeMapPlaceKey, toPublicMapPlace } from '@/lib/map/place'; -import type { MapPlaceStore } from '@/lib/map/store'; +import { + normalizeMapPlace, + normalizeMapPlaceFilter, + normalizeMapPlaceKey, + normalizePlaceOrigin, + toPublicMapPlace, +} from '@/lib/map/place'; +import type { MapPlaceListFilter, MapPlaceStore } from '@/lib/map/store'; export type MapRouteDeps = { store: MapPlaceStore; @@ -62,10 +68,51 @@ export function mapRoutes(deps: MapRouteDeps): Hono { } limit = n; } - const places = deps.store.list(limit).map((place) => toPublicMapPlace(place)); + const originQuery = c.req.query('origin'); + let origin: string | undefined; + if (originQuery !== undefined) { + const parsedOrigin = normalizePlaceOrigin(originQuery); + if (!parsedOrigin.ok) { + return c.json({ error: parsedOrigin.error }, 400); + } + origin = parsedOrigin.value; + } + const parsedFilter = normalizeMapPlaceFilter( + c.req.query('country'), + c.req.query('shopName'), + c.req.query('blockchain'), + c.req.query('asset'), + ); + if (!parsedFilter.ok) { + return c.json({ error: parsedFilter.error }, 400); + } + const filter: MapPlaceListFilter = { ...parsedFilter.value }; + if (origin !== undefined) { + filter.origin = origin; + } + const places = deps.store.list(limit, filter).map((place) => toPublicMapPlace(place)); return c.json({ places }); }); + app.get('/filters', (c) => { + const parsed = normalizeMapPlaceFilter( + undefined, + undefined, + c.req.query('blockchain'), + undefined, + ); + if (!parsed.ok) { + return c.json({ error: parsed.error }, 400); + } + const values = deps.store.filters(parsed.value.blockchain); + return c.json({ + shopNames: ['SPAR', 'others'], + countries: values.countries, + blockchains: values.blockchains, + assets: values.assets, + }); + }); + app.post('/places', async (c) => { const auth = checkIngest(deps.ingestToken, c.req.header('authorization')); if (auth === 'unconfigured') { diff --git a/src/server.ts b/src/server.ts index d1a217535..266da93e1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,7 +13,7 @@ export type AppDeps = { }; /** - * HTTP API. `GET /healthz` and `GET /map/places` are public. + * HTTP API. `GET /healthz`, `GET /map/places`, and `GET /map/filters` are public. * `POST /map/places` creates a pin once. `PUT /map/places` updates a pin. * `DELETE /map/places` removes a pin. * From 42890faefdd94da22c873bfcc6512e5e9b4a92bc Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:18:50 +0200 Subject: [PATCH 2/3] Cover map filter pairs, limit order, and the SPAR shop backfill --- e2e/map.spec.ts | 114 +++++++++++++++++++++++++++++++ e2e/sqlite-backfill.ts | 109 +++++++++++++++++++++++++++++ src/__tests__/map/routes.test.ts | 30 ++++++++ src/lib/map/sqlite.ts | 4 +- 4 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 e2e/sqlite-backfill.ts diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 8899df6c2..e4dec303f 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { expect, test } from '@playwright/test'; const body = { @@ -257,3 +258,116 @@ test('Function: normalizeMapPlaceKey — PUT updates a pin and DELETE removes it expect(again.status()).toBe(200); expect(await again.json()).toEqual({ deleted: false }); }); + +test('Function: SqliteMapPlaceStore — limit is applied after the country filter', async ({ + request, +}) => { + const older = { + origin: 'dfx', + externalId: 'e2e-limit-ch', + name: 'Limit Older CH', + lat: 47.37, + lon: 8.54, + category: 'groceries', + country: 'CH', + }; + const newer = { + origin: 'dfx', + externalId: 'e2e-limit-de', + name: 'Limit Newer DE', + lat: 47.37, + lon: 8.54, + category: 'groceries', + country: 'DE', + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: older, + }) + ).status(), + ).toBe(201); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: newer, + }) + ).status(), + ).toBe(201); + + const newest = await request.get('/map/places?limit=1'); + expect(newest.status()).toBe(200); + const newestJson = (await newest.json()) as { places: Array<{ name: string }> }; + expect(newestJson.places).toHaveLength(1); + expect(newestJson.places[0]?.name).toBe('Limit Newer DE'); + + const ch = await request.get('/map/places?limit=1&country=CH'); + expect(ch.status()).toBe(200); + const chJson = (await ch.json()) as { places: Array<{ name: string }> }; + expect(chJson.places).toHaveLength(1); + expect(chJson.places[0]?.name).toBe('Limit Older CH'); +}); + +test('Function: SqliteMapPlaceStore — delete removes the pin and its support from filters', async ({ + request, +}) => { + const pin = { + origin: 'dfx', + externalId: 'e2e-support-delete', + name: 'Support Delete Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + supports: [{ blockchain: 'Plasma', asset: 'ONDO' }], + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: pin, + }) + ).status(), + ).toBe(201); + + const filters = await request.get('/map/filters'); + expect(filters.status()).toBe(200); + const filterJson = (await filters.json()) as { + shopNames: string[]; + countries: string[]; + blockchains: string[]; + assets: string[]; + }; + expect(filterJson.blockchains).toContain('Plasma'); + expect(filterJson.assets).toContain('ONDO'); + + const deleted = await request.delete('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: { origin: 'dfx', externalId: 'e2e-support-delete' }, + }); + expect(deleted.status()).toBe(200); + expect(await deleted.json()).toEqual({ deleted: true }); + + const afterFilters = await request.get('/map/filters'); + expect(afterFilters.status()).toBe(200); + const afterFilterJson = (await afterFilters.json()) as { + shopNames: string[]; + countries: string[]; + blockchains: string[]; + assets: string[]; + }; + expect(afterFilterJson.blockchains).not.toContain('Plasma'); + expect(afterFilterJson.assets).not.toContain('ONDO'); + + const afterDelete = (await (await request.get('/map/places')).json()) as { + places: Array<{ name: string }>; + }; + expect(afterDelete.places.some((row) => row.name === 'Support Delete Pin')).toBe(false); +}); + +test('Function: SqliteMapPlaceStore — opening backfills only a null SPAR shop name', () => { + const stdout = execFileSync('bun', ['e2e/sqlite-backfill.ts'], { encoding: 'utf8' }); + expect(stdout.trim()).toBe('ok'); +}); diff --git a/e2e/sqlite-backfill.ts b/e2e/sqlite-backfill.ts new file mode 100644 index 000000000..88624e8e3 --- /dev/null +++ b/e2e/sqlite-backfill.ts @@ -0,0 +1,109 @@ +import { Database } from 'bun:sqlite'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SqliteMapPlaceStore } from '../src/lib/map/sqlite'; +import { MAP_PLACE_SCHEMA_SQL } from '../src/lib/map/store'; + +const dir = mkdtempSync(join(tmpdir(), 'sqlite-backfill-')); +const filename = join(dir, 'map.sqlite'); +let seed: Database | undefined; +let store: SqliteMapPlaceStore | undefined; +try { + seed = new Database(filename); + const statements = MAP_PLACE_SCHEMA_SQL.split(';') + .map((part) => part.trim()) + .filter((part) => part !== ''); + for (const sql of statements) { + seed.exec(sql); + } + const insert = seed.query( + `INSERT INTO map_place ( + id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider, country, shop_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + insert.run( + 'spar-null-row', + 'spar', + 'spar-null', + 'Pin', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-01T00:00:00.000Z', + 'DFX.swiss', + null, + null, + ); + insert.run( + 'dfx-null-row', + 'dfx', + 'dfx-null', + 'Pin', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-02T00:00:00.000Z', + 'DFX.swiss', + null, + null, + ); + insert.run( + 'spar-migros-row', + 'spar', + 'spar-migros', + 'Pin', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-03T00:00:00.000Z', + 'DFX.swiss', + null, + 'Migros', + ); + seed.close(); + seed = undefined; + store = new SqliteMapPlaceStore(filename); + const listed = store.list(10); + const sparNull = listed.find((row) => row.externalId === 'spar-null'); + const dfxNull = listed.find((row) => row.externalId === 'dfx-null'); + const sparMigros = listed.find((row) => row.externalId === 'spar-migros'); + if ( + sparNull !== undefined && + dfxNull !== undefined && + sparMigros !== undefined && + sparNull.shopName === 'SPAR' && + dfxNull.shopName === null && + sparMigros.shopName === 'Migros' + ) { + process.stdout.write('ok\n'); + } else { + process.stdout.write( + `${JSON.stringify({ + 'spar-null': sparNull === undefined ? null : sparNull.shopName, + 'dfx-null': dfxNull === undefined ? null : dfxNull.shopName, + 'spar-migros': sparMigros === undefined ? null : sparMigros.shopName, + })}\n`, + ); + process.exitCode = 1; + } +} catch (error) { + process.stdout.write( + `${JSON.stringify({ error: error instanceof Error ? error.message : error })}\n`, + ); + process.exitCode = 1; +} finally { + try { + if (seed !== undefined) { + seed.close(); + } + if (store !== undefined) { + store.close(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/src/__tests__/map/routes.test.ts b/src/__tests__/map/routes.test.ts index 68fa3858c..5ec15013d 100644 --- a/src/__tests__/map/routes.test.ts +++ b/src/__tests__/map/routes.test.ts @@ -405,6 +405,36 @@ describe('map routes', () => { }); }); + it('does not treat blockchain and asset on different support rows as one pair', async () => { + const api = app({ token: 'secret' }); + const created = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + ...body, + externalId: 'split-pair', + supports: [ + { blockchain: 'Polygon', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + ], + }), + }); + expect(created.status).toBe(201); + const mismatch = await api.request('/map/places?blockchain=Ethereum&asset=ZCHF'); + expect(mismatch.status).toBe(200); + const mismatchJson = (await mismatch.json()) as { places: unknown[] }; + expect(mismatchJson.places).toHaveLength(0); + const eth = await api.request('/map/places?blockchain=Ethereum&asset=ETH'); + expect(eth.status).toBe(200); + const ethJson = (await eth.json()) as { places: Array<{ name: string }> }; + expect(ethJson.places).toHaveLength(1); + expect(ethJson.places[0]?.name).toBe(body.name); + const polygon = await api.request('/map/places?blockchain=Polygon&asset=ZCHF'); + expect(polygon.status).toBe(200); + const polygonJson = (await polygon.json()) as { places: unknown[] }; + expect(polygonJson.places).toHaveLength(1); + }); + it('returns a stored dEURO asset unchanged', async () => { const api = app({ token: 'secret' }); const created = await api.request('/map/places', { diff --git a/src/lib/map/sqlite.ts b/src/lib/map/sqlite.ts index c63445085..0260e76b3 100644 --- a/src/lib/map/sqlite.ts +++ b/src/lib/map/sqlite.ts @@ -135,8 +135,8 @@ function replaceSupportRows(db: Database, placeId: string, supports: MapPlaceSup /** * SQLite {@link MapPlaceStore}. Opened by the process entrypoint. - * Unit tests use {@link MemoryMapPlaceStore}; this driver is exercised by - * the HTTP end-to-end run. + * Insert, list, and delete are exercised by the HTTP end-to-end run. + * The SPAR shop-name backfill is exercised by e2e/sqlite-backfill.ts. */ export class SqliteMapPlaceStore implements MapPlaceStore { readonly #db: Database; From 2afcc89303cd9831ef68f203adb5ce0dfd520c57 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:19:47 +0200 Subject: [PATCH 3/3] Cover a split support pair and the row delete keeps A blockchain and asset filter matches one support row, and deleting a pin leaves a different asset on the same chain in place. --- e2e/map.spec.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index e4dec303f..9cb617e8f 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -193,6 +193,40 @@ test('Function: normalizeMapPlaceFilter — filters one row', async ({ request } expect(filterJson.assets).toContain('ZCHF'); }); +test('Function: SqliteMapPlaceStore — a pair is one support row', async ({ request }) => { + const pin = { + origin: 'dfx', + externalId: 'e2e-split-pair', + name: 'Split Pair Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + supports: [ + { blockchain: 'Polygon', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + ], + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: pin, + }) + ).status(), + ).toBe(201); + + const names = async (query: string): Promise => { + const response = await request.get(`/map/places?${query}`); + expect(response.status()).toBe(200); + const json = (await response.json()) as { places: Array<{ name: string }> }; + return json.places.map((row) => row.name); + }; + + expect(await names('blockchain=Ethereum&asset=ZCHF')).not.toContain('Split Pair Pin'); + expect(await names('blockchain=Ethereum&asset=ETH')).toContain('Split Pair Pin'); + expect(await names('blockchain=Polygon&asset=ZCHF')).toContain('Split Pair Pin'); +}); + test('Function: normalizeMapPlaceKey — PUT updates a pin and DELETE removes it', async ({ request, }) => { @@ -331,6 +365,23 @@ test('Function: SqliteMapPlaceStore — delete removes the pin and its support f }) ).status(), ).toBe(201); + const keep = { + origin: 'dfx', + externalId: 'e2e-support-keep', + name: 'Support Keep Pin', + lat: 46.95, + lon: 7.44, + category: 'groceries', + supports: [{ blockchain: 'Ethereum', asset: 'dEURO' }], + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: keep, + }) + ).status(), + ).toBe(201); const filters = await request.get('/map/filters'); expect(filters.status()).toBe(200); @@ -365,6 +416,13 @@ test('Function: SqliteMapPlaceStore — delete removes the pin and its support f places: Array<{ name: string }>; }; expect(afterDelete.places.some((row) => row.name === 'Support Delete Pin')).toBe(false); + expect(afterDelete.places.some((row) => row.name === 'Support Keep Pin')).toBe(true); + + const kept = await request.get('/map/places?blockchain=Ethereum&asset=dEURO'); + expect(kept.status()).toBe(200); + const keptJson = (await kept.json()) as { places: Array<{ name: string }> }; + expect(keptJson.places.some((row) => row.name === 'Support Keep Pin')).toBe(true); + expect(keptJson.places.some((row) => row.name === 'Support Delete Pin')).toBe(false); }); test('Function: SqliteMapPlaceStore — opening backfills only a null SPAR shop name', () => {