Enhance API documentation and improve marker functionality

- Updated API documentation for clarity and consistency, including detailed descriptions of authentication and user account endpoints.
- Added a new cave marker image to enhance visual representation in the frontend.
- Implemented normalization for cave marker images during upload to ensure consistent storage format.
- Expanded test coverage for client services, including new tests for marker uploads and image normalization.
This commit is contained in:
2026-03-04 16:57:43 +03:00
parent 40945c818b
commit 3968bdc76f
5 changed files with 883 additions and 846 deletions

View File

@@ -1,79 +1,79 @@
# HTTP API # HTTP API
The API is available under the `/map/api/` prefix. Requests requiring authentication use a `session` cookie (set on login). The API is available under the `/map/api/` prefix. Requests requiring authentication use a `session` cookie (set on login).
## Authentication ## Authentication
- **POST /map/api/login** — sign in. Body: `{"user":"...","pass":"..."}`. On success returns JSON with user data and sets a session cookie. On first run, bootstrap is available: logging in as `admin` with the password from `HNHMAP_BOOTSTRAP_PASSWORD` creates the first admin user. For users created via OAuth (no password), returns 401 with `{"error":"Use OAuth to sign in"}`. - **POST /map/api/login** — sign in. Body: `{"user":"...","pass":"..."}`. On success returns JSON with user data and sets a session cookie. On first run, bootstrap is available: logging in as `admin` with the password from `HNHMAP_BOOTSTRAP_PASSWORD` creates the first admin user. For users created via OAuth (no password), returns 401 with `{"error":"Use OAuth to sign in"}`.
- **GET /map/api/me** — current user (by session). Response: `username`, `auths`, and optionally `tokens`, `prefix`, `email` (string, optional — for Gravatar and display). - **GET /map/api/me** — current user (by session). Response: `username`, `auths`, and optionally `tokens`, `prefix`, `email` (string, optional — for Gravatar and display).
- **POST /map/api/logout** — sign out (invalidates the session). - **POST /map/api/logout** — sign out (invalidates the session).
- **GET /map/api/setup** — check if initial setup is needed. Response: `{"setupRequired": true|false}`. - **GET /map/api/setup** — check if initial setup is needed. Response: `{"setupRequired": true|false}`.
### OAuth ### OAuth
- **GET /map/api/oauth/providers** — list of configured OAuth providers. Response: `["google", ...]`. - **GET /map/api/oauth/providers** — list of configured OAuth providers. Response: `["google", ...]`.
- **GET /map/api/oauth/{provider}/login** — redirect to the provider's authorization page. Query: `redirect` — path to redirect to after successful login (e.g. `/profile`). - **GET /map/api/oauth/{provider}/login** — redirect to the provider's authorization page. Query: `redirect` — path to redirect to after successful login (e.g. `/profile`).
- **GET /map/api/oauth/{provider}/callback** — callback from the provider (called automatically). Exchanges the `code` for tokens, creates or finds the user, creates a session, and redirects to `/profile` or the `redirect` from state. - **GET /map/api/oauth/{provider}/callback** — callback from the provider (called automatically). Exchanges the `code` for tokens, creates or finds the user, creates a session, and redirects to `/profile` or the `redirect` from state.
## User account ## User account
- **PATCH /map/api/me** — update current user. Body: `{"email": "..."}`. Used to set or change the user's email (for Gravatar and profile display). Requires a valid session. - **PATCH /map/api/me** — update current user. Body: `{"email": "..."}`. Used to set or change the user's email (for Gravatar and profile display). Requires a valid session.
- **POST /map/api/me/tokens** — generate a new upload token (requires `upload` permission). Response: `{"tokens": ["...", ...]}`. - **POST /map/api/me/tokens** — generate a new upload token (requires `upload` permission). Response: `{"tokens": ["...", ...]}`.
- **POST /map/api/me/password** — change password. Body: `{"pass":"..."}`. - **POST /map/api/me/password** — change password. Body: `{"pass":"..."}`.
## Map data ## Map data
- **GET /map/api/config** — client configuration (title, auths). Requires a session. - **GET /map/api/config** — client configuration (title, auths). Requires a session.
- **GET /map/api/v1/characters** — list of characters on the map (requires `map` permission; `markers` permission needed to see data). Each character object includes `ownedByMe` (boolean), which is true when the character was last updated by one of the current user's upload tokens. - **GET /map/api/v1/characters** — list of characters on the map (requires `map` permission; `markers` permission needed to see data). Each character object includes `ownedByMe` (boolean), which is true when the character was last updated by one of the current user's upload tokens.
- **GET /map/api/v1/markers** — markers (requires `map` permission; `markers` permission needed to see data). - **GET /map/api/v1/markers** — markers (requires `map` permission; `markers` permission needed to see data).
- **GET /map/api/maps** — list of maps (filtered by permissions and hidden status). For non-admin users hidden maps are excluded; for admin, the response may include hidden maps (client should hide them in map selector if needed). - **GET /map/api/maps** — list of maps (filtered by permissions and hidden status). For non-admin users hidden maps are excluded; for admin, the response may include hidden maps (client should hide them in map selector if needed).
## Admin (all endpoints below require `admin` permission) ## Admin (all endpoints below require `admin` permission)
- **GET /map/api/admin/users** — list of usernames. - **GET /map/api/admin/users** — list of usernames.
- **POST /map/api/admin/users** — create or update a user. Body: `{"user":"...","pass":"...","auths":["admin","map",...]}`. - **POST /map/api/admin/users** — create or update a user. Body: `{"user":"...","pass":"...","auths":["admin","map",...]}`.
- **GET /map/api/admin/users/:name** — user data. - **GET /map/api/admin/users/:name** — user data.
- **DELETE /map/api/admin/users/:name** — delete a user. - **DELETE /map/api/admin/users/:name** — delete a user.
- **GET /map/api/admin/settings** — settings (prefix, defaultHide, title). - **GET /map/api/admin/settings** — settings (prefix, defaultHide, title).
- **POST /map/api/admin/settings** — save settings. Body: `{"prefix":"...","defaultHide":true|false,"title":"..."}` (all fields optional). - **POST /map/api/admin/settings** — save settings. Body: `{"prefix":"...","defaultHide":true|false,"title":"..."}` (all fields optional).
- **GET /map/api/admin/maps** — list of maps for the admin panel. - **GET /map/api/admin/maps** — list of maps for the admin panel.
- **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority). - **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority).
- **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility. - **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility.
- **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database. - **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database.
- **POST /map/api/admin/rebuildZooms** — start rebuilding tile zoom levels from base tiles in the background. Returns **202 Accepted** immediately; the operation can take minutes when there are many grids. The client may poll **GET /map/api/admin/rebuildZooms/status** until `{"running": false}` and then refresh the map. - **POST /map/api/admin/rebuildZooms** — start rebuilding tile zoom levels from base tiles in the background. Returns **202 Accepted** immediately; the operation can take minutes when there are many grids. The client may poll **GET /map/api/admin/rebuildZooms/status** until `{"running": false}` and then refresh the map.
- **GET /map/api/admin/rebuildZooms/status** — returns `{"running": true|false}` indicating whether a rebuild started via POST rebuildZooms is still in progress. - **GET /map/api/admin/rebuildZooms/status** — returns `{"running": true|false}` indicating whether a rebuild started via POST rebuildZooms is still in progress.
- **GET /map/api/admin/export** — download data export (ZIP). - **GET /map/api/admin/export** — download data export (ZIP).
- **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers). - **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers).
- **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`. - **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`.
- **GET /map/api/admin/setCoords** — shift grid coordinates. Query: `map`, `fx`, `fy`, `tx`, `ty`. - **GET /map/api/admin/setCoords** — shift grid coordinates. Query: `map`, `fx`, `fy`, `tx`, `ty`.
- **GET /map/api/admin/hideMarker** — hide a marker. Query: `id`. - **GET /map/api/admin/hideMarker** — hide a marker. Query: `id`.
## Game client ## Game client
The game client (e.g. Purus Pasta) communicates via `/client/{token}/...` endpoints using token-based authentication. The game client (e.g. Purus Pasta) communicates via `/client/{token}/...` endpoints using token-based authentication.
- **GET /client/{token}/checkVersion** — check client protocol version. Query: `version`. Returns 200 if matching, 400 otherwise. - **GET /client/{token}/checkVersion** — check client protocol version. Query: `version`. Returns 200 if matching, 400 otherwise.
- **GET /client/{token}/locate** — get grid coordinates. Query: `gridID`. Response: `mapid;x;y`. - **GET /client/{token}/locate** — get grid coordinates. Query: `gridID`. Response: `mapid;x;y`.
- **POST /client/{token}/gridUpdate** — report visible grids and receive upload requests. - **POST /client/{token}/gridUpdate** — report visible grids and receive upload requests.
- **POST /client/{token}/gridUpload** — upload a tile image (multipart). - **POST /client/{token}/gridUpload** — upload a tile image (multipart).
- **POST /client/{token}/positionUpdate** — update character positions. - **POST /client/{token}/positionUpdate** — update character positions.
- **POST /client/{token}/markerUpdate** — upload markers. - **POST /client/{token}/markerUpdate** — upload markers.
## SSE (Server-Sent Events) ## SSE (Server-Sent Events)
- **GET /map/updates** — real-time tile and merge updates. Requires a session with `map` permission. Sends an initial `data:` message with an empty tile cache array `[]`, then incremental `data:` messages with tile cache updates and `event: merge` messages for map merges. The client requests tiles with `cache=0` when not yet in cache. - **GET /map/updates** — real-time tile and merge updates. Requires a session with `map` permission. Sends an initial `data:` message with an empty tile cache array `[]`, then incremental `data:` messages with tile cache updates and `event: merge` messages for map merges. The client requests tiles with `cache=0` when not yet in cache.
## Tile images ## Tile images
- **GET /map/grids/{mapid}/{zoom}/{x}_{y}.png** — tile image. Requires a session with `map` permission. Returns the tile image or a transparent 1×1 PNG if the tile does not exist. - **GET /map/grids/{mapid}/{zoom}/{x}_{y}.png** — tile image. Requires a session with `map` permission. Returns the tile image or a transparent 1×1 PNG if the tile does not exist.
## Response codes ## Response codes
- **200** — success. - **200** — success.
- **400** — bad request (wrong method, body, or parameters). - **400** — bad request (wrong method, body, or parameters).
- **401** — unauthorized (missing or invalid session). - **401** — unauthorized (missing or invalid session).
- **403** — forbidden (insufficient permissions). - **403** — forbidden (insufficient permissions).
- **404** — not found. - **404** — not found.
- **500** — internal error. - **500** — internal error.
Error format: JSON body `{"error": "message", "code": "CODE"}`. Error format: JSON body `{"error": "message", "code": "CODE"}`.

View File

@@ -1,159 +1,165 @@
import type L from 'leaflet' import type L from 'leaflet'
import { HnHMaxZoom, ImageIcon, TileSize } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom, ImageIcon, TileSize } from '~/lib/LeafletCustomTypes'
export interface MarkerData { export interface MarkerData {
id: number id: number
position: { x: number; y: number } position: { x: number; y: number }
name: string name: string
image: string image: string
hidden: boolean hidden: boolean
map: number map: number
} }
export interface MapViewRef { export interface MapViewRef {
map: L.Map map: L.Map
mapid: number mapid: number
markerLayer: L.LayerGroup markerLayer: L.LayerGroup
} }
export interface MapMarker { export interface MapMarker {
id: number id: number
position: { x: number; y: number } position: { x: number; y: number }
name: string name: string
image: string image: string
type: string type: string
text: string text: string
value: number value: number
hidden: boolean hidden: boolean
map: number map: number
leafletMarker: L.Marker | null leafletMarker: L.Marker | null
remove: (mapview: MapViewRef) => void remove: (mapview: MapViewRef) => void
add: (mapview: MapViewRef) => void add: (mapview: MapViewRef) => void
update: (mapview: MapViewRef, updated: MarkerData | MapMarker) => void update: (mapview: MapViewRef, updated: MarkerData | MapMarker) => void
jumpTo: (map: L.Map) => void jumpTo: (map: L.Map) => void
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
setContextMenu: (callback: (e: L.LeafletMouseEvent) => void) => void setContextMenu: (callback: (e: L.LeafletMouseEvent) => void) => void
} }
function detectType(name: string): string { function detectType(name: string): string {
if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest' if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest'
if (name === 'custom') return 'custom' if (name === 'custom') return 'custom'
return name.substring('gfx/terobjs/mm/'.length) return name.substring('gfx/terobjs/mm/'.length)
} }
export interface MarkerIconOptions { export interface MarkerIconOptions {
/** Resolves relative icon path to absolute URL (e.g. with app base path). */ /** Resolves relative icon path to absolute URL (e.g. with app base path). */
resolveIconUrl: (path: string) => string resolveIconUrl: (path: string) => string
/** Optional fallback URL when the icon image fails to load. */ /** Optional fallback URL when the icon image fails to load. */
fallbackIconUrl?: string fallbackIconUrl?: string
} }
export type LeafletApi = L export type LeafletApi = L
export function createMarker( export function createMarker(
data: MarkerData, data: MarkerData,
iconOptions: MarkerIconOptions | undefined, iconOptions: MarkerIconOptions | undefined,
L: LeafletApi L: LeafletApi
): MapMarker { ): MapMarker {
let leafletMarker: L.Marker | null = null let leafletMarker: L.Marker | null = null
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
let onContext: ((e: L.LeafletMouseEvent) => void) | null = null let onContext: ((e: L.LeafletMouseEvent) => void) | null = null
const marker: MapMarker = { const marker: MapMarker = {
id: data.id, id: data.id,
position: { ...data.position }, position: { ...data.position },
name: data.name, name: data.name,
image: data.image, image: data.image,
type: detectType(data.image), type: detectType(data.image),
text: data.name, text: data.name,
value: data.id, value: data.id,
hidden: data.hidden, hidden: data.hidden,
map: data.map, map: data.map,
get leafletMarker() { get leafletMarker() {
return leafletMarker return leafletMarker
}, },
remove(_mapview: MapViewRef): void { remove(_mapview: MapViewRef): void {
if (leafletMarker) { if (leafletMarker) {
leafletMarker.remove() leafletMarker.remove()
leafletMarker = null leafletMarker = null
} }
}, },
add(mapview: MapViewRef): void { add(mapview: MapViewRef): void {
if (!marker.hidden) { if (!marker.hidden) {
const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path) const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path)
const fallback = iconOptions?.fallbackIconUrl const fallback = iconOptions?.fallbackIconUrl
let icon: L.Icon const iconUrl =
if (marker.image === 'gfx/terobjs/mm/custom') { marker.name === 'Cave' && marker.image === 'gfx/terobjs/mm/custom'
icon = new ImageIcon({ ? resolve('gfx/terobjs/mm/cave.png')
iconUrl: resolve('gfx/terobjs/mm/custom.png'), : marker.image === 'gfx/terobjs/mm/custom'
iconSize: [21, 23], ? resolve('gfx/terobjs/mm/custom.png')
iconAnchor: [11, 21], : resolve(`${marker.image}.png`)
popupAnchor: [1, 3], let icon: L.Icon
tooltipAnchor: [1, 3], if (marker.image === 'gfx/terobjs/mm/custom' && marker.name !== 'Cave') {
fallbackIconUrl: fallback, icon = new ImageIcon({
}) iconUrl,
} else { iconSize: [21, 23],
icon = new ImageIcon({ iconAnchor: [11, 21],
iconUrl: resolve(`${marker.image}.png`), popupAnchor: [1, 3],
iconSize: [32, 32], tooltipAnchor: [1, 3],
fallbackIconUrl: fallback, fallbackIconUrl: fallback,
}) })
} } else {
icon = new ImageIcon({
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom) iconUrl,
leafletMarker = L.marker(position, { icon }) iconSize: [32, 32],
const gridX = Math.floor(marker.position.x / TileSize) fallbackIconUrl: fallback,
const gridY = Math.floor(marker.position.y / TileSize) })
const tooltipContent = `${marker.name} · ${gridX}, ${gridY}` }
leafletMarker.bindTooltip(tooltipContent, {
direction: 'top', const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
permanent: false, leafletMarker = L.marker(position, { icon })
offset: L.point(0, -14), const gridX = Math.floor(marker.position.x / TileSize)
}) const gridY = Math.floor(marker.position.y / TileSize)
leafletMarker.addTo(mapview.markerLayer) const tooltipContent = `${marker.name} · ${gridX}, ${gridY}`
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.() leafletMarker.bindTooltip(tooltipContent, {
if (markerEl) markerEl.setAttribute('aria-label', marker.name) direction: 'top',
leafletMarker.on('click', (e: L.LeafletMouseEvent) => { permanent: false,
if (onClick) onClick(e) offset: L.point(0, -14),
}) })
leafletMarker.on('contextmenu', (e: L.LeafletMouseEvent) => { leafletMarker.addTo(mapview.markerLayer)
if (onContext) onContext(e) const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
}) if (markerEl) markerEl.setAttribute('aria-label', marker.name)
} leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
}, if (onClick) onClick(e)
})
update(mapview: MapViewRef, updated: MarkerData | MapMarker): void { leafletMarker.on('contextmenu', (e: L.LeafletMouseEvent) => {
marker.position = { ...updated.position } if (onContext) onContext(e)
marker.name = updated.name })
marker.hidden = updated.hidden }
marker.map = updated.map },
if (leafletMarker) {
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom) update(mapview: MapViewRef, updated: MarkerData | MapMarker): void {
leafletMarker.setLatLng(position) marker.position = { ...updated.position }
const gridX = Math.floor(updated.position.x / TileSize) marker.name = updated.name
const gridY = Math.floor(updated.position.y / TileSize) marker.hidden = updated.hidden
leafletMarker.setTooltipContent(`${marker.name} · ${gridX}, ${gridY}`) marker.map = updated.map
} if (leafletMarker) {
}, const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
leafletMarker.setLatLng(position)
jumpTo(map: L.Map): void { const gridX = Math.floor(updated.position.x / TileSize)
if (leafletMarker) { const gridY = Math.floor(updated.position.y / TileSize)
const position = map.unproject([marker.position.x, marker.position.y], HnHMaxZoom) leafletMarker.setTooltipContent(`${marker.name} · ${gridX}, ${gridY}`)
leafletMarker.setLatLng(position) }
} },
},
jumpTo(map: L.Map): void {
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void { if (leafletMarker) {
onClick = callback const position = map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
}, leafletMarker.setLatLng(position)
}
setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void { },
onContext = callback
}, setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
} onClick = callback
},
return marker
} setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void {
onContext = callback
},
}
return marker
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,93 +1,121 @@
package services_test package services_test
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"testing" "testing"
"github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services" "github.com/andyleap/hnh-map/internal/app/services"
"github.com/andyleap/hnh-map/internal/app/store" "github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
) )
func TestFixMultipartContentType_NeedsQuoting(t *testing.T) { func TestFixMultipartContentType_NeedsQuoting(t *testing.T) {
ct := "multipart/form-data; boundary=----WebKitFormBoundary=abc123" ct := "multipart/form-data; boundary=----WebKitFormBoundary=abc123"
got := services.FixMultipartContentType(ct) got := services.FixMultipartContentType(ct)
want := `multipart/form-data; boundary="----WebKitFormBoundary=abc123"` want := `multipart/form-data; boundary="----WebKitFormBoundary=abc123"`
if got != want { if got != want {
t.Fatalf("expected %q, got %q", want, got) t.Fatalf("expected %q, got %q", want, got)
} }
} }
func TestFixMultipartContentType_AlreadyQuoted(t *testing.T) { func TestFixMultipartContentType_AlreadyQuoted(t *testing.T) {
ct := `multipart/form-data; boundary="----WebKitFormBoundary"` ct := `multipart/form-data; boundary="----WebKitFormBoundary"`
got := services.FixMultipartContentType(ct) got := services.FixMultipartContentType(ct)
if got != ct { if got != ct {
t.Fatalf("expected unchanged, got %q", got) t.Fatalf("expected unchanged, got %q", got)
} }
} }
func TestFixMultipartContentType_Normal(t *testing.T) { func TestFixMultipartContentType_Normal(t *testing.T) {
ct := "multipart/form-data; boundary=----WebKitFormBoundary" ct := "multipart/form-data; boundary=----WebKitFormBoundary"
got := services.FixMultipartContentType(ct) got := services.FixMultipartContentType(ct)
if got != ct { if got != ct {
t.Fatalf("expected unchanged, got %q", got) t.Fatalf("expected unchanged, got %q", got)
} }
} }
func newTestClientService(t *testing.T) (*services.ClientService, *store.Store) { func newTestClientService(t *testing.T) (*services.ClientService, *store.Store) {
t.Helper() t.Helper()
db := newTestDB(t) db := newTestDB(t)
st := store.New(db) st := store.New(db)
mapSvc := services.NewMapService(services.MapServiceDeps{ mapSvc := services.NewMapService(services.MapServiceDeps{
Store: st, Store: st,
GridStorage: t.TempDir(), GridStorage: t.TempDir(),
GridUpdates: &app.Topic[app.TileData]{}, GridUpdates: &app.Topic[app.TileData]{},
}) })
client := services.NewClientService(services.ClientServiceDeps{ client := services.NewClientService(services.ClientServiceDeps{
Store: st, Store: st,
MapSvc: mapSvc, MapSvc: mapSvc,
WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) }, WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) },
}) })
return client, st return client, st
} }
func TestClientLocate_Found(t *testing.T) { func TestClientLocate_Found(t *testing.T) {
client, st := newTestClientService(t) client, st := newTestClientService(t)
ctx := context.Background() ctx := context.Background()
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}} gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}}
raw, _ := json.Marshal(gd) raw, _ := json.Marshal(gd)
if err := st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutGrid(tx, "g1", raw) return st.PutGrid(tx, "g1", raw)
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
result, err := client.Locate(ctx, "g1") result, err := client.Locate(ctx, "g1")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result != "1;2;3" { if result != "1;2;3" {
t.Fatalf("expected 1;2;3, got %q", result) t.Fatalf("expected 1;2;3, got %q", result)
} }
} }
func TestClientLocate_NotFound(t *testing.T) { func TestClientLocate_NotFound(t *testing.T) {
client, _ := newTestClientService(t) client, _ := newTestClientService(t)
_, err := client.Locate(context.Background(), "ghost") _, err := client.Locate(context.Background(), "ghost")
if err == nil { if err == nil {
t.Fatal("expected error for unknown grid") t.Fatal("expected error for unknown grid")
} }
} }
func TestClientProcessGridUpdate_EmptyGrids(t *testing.T) { func TestClientProcessGridUpdate_EmptyGrids(t *testing.T) {
client, _ := newTestClientService(t) client, _ := newTestClientService(t)
ctx := context.Background() ctx := context.Background()
result, err := client.ProcessGridUpdate(ctx, services.GridUpdate{Grids: [][]string{}}) result, err := client.ProcessGridUpdate(ctx, services.GridUpdate{Grids: [][]string{}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result == nil { if result == nil {
t.Fatal("expected non-nil result") t.Fatal("expected non-nil result")
} }
} }
func TestUploadMarkers_NormalizesCaveImage(t *testing.T) {
client, st := newTestClientService(t)
ctx := context.Background()
body := []byte(`[{"Name":"Cave","GridID":"g1","X":10,"Y":20,"Image":"gfx/terobjs/mm/custom"}]`)
if err := client.UploadMarkers(ctx, body); err != nil {
t.Fatal(err)
}
var stored app.Marker
if err := st.View(ctx, func(tx *bbolt.Tx) error {
grid := st.GetMarkersGridBucket(tx)
if grid == nil {
t.Fatal("markers grid bucket not found")
return nil
}
v := grid.Get([]byte("g1_10_20"))
if v == nil {
t.Fatal("marker g1_10_20 not found")
return nil
}
return json.Unmarshal(v, &stored)
}); err != nil {
t.Fatal(err)
}
if stored.Image != "gfx/terobjs/mm/cave" {
t.Fatalf("expected stored marker Image gfx/terobjs/mm/cave, got %q", stored.Image)
}
}