diff --git a/src/lib/server/integrations/komodo.ts b/src/lib/server/integrations/komodo.ts index a192287..b0c8de2 100644 --- a/src/lib/server/integrations/komodo.ts +++ b/src/lib/server/integrations/komodo.ts @@ -525,13 +525,16 @@ export type KomodoTagResult = | { ok: true; id: string } | { ok: false; message: string }; -// Komodo serializes Mongo ObjectIds as `_id` in the raw mongo docs and as -// `id` in the API DTOs (rust serde default). Accept either everywhere. +// Accept API string IDs and raw Mongo Extended JSON: { _id: { $oid: "..." } }. function extractTagId(t: unknown): string | null { if (!t || typeof t !== 'object') return null; const o = t as { id?: unknown; _id?: unknown }; if (typeof o.id === 'string' && o.id.length > 0) return o.id; if (typeof o._id === 'string' && o._id.length > 0) return o._id; + if (o._id && typeof o._id === 'object') { + const oid = (o._id as { $oid?: unknown }).$oid; + if (typeof oid === 'string' && oid.length > 0) return oid; + } return null; } diff --git a/tests/unit/komodo-tags.test.ts b/tests/unit/komodo-tags.test.ts new file mode 100644 index 0000000..2c105c8 --- /dev/null +++ b/tests/unit/komodo-tags.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { komodoEnsureTag, komodoUpdateTagsOnResource } from '../../src/lib/server/integrations/komodo.js'; + +vi.mock('../../src/lib/server/config.js', () => ({ + config: { + komodo: { address: 'http://komodo.test', apiKey: 'test', apiSecret: 'test' }, + httpTimeoutMs: 1000 + }, + komodoConfigured: () => true +})); + +const fetchMock = vi.fn(); +const gameId = '6aa1fb6bc27e735cc0767f5a'; +const mapId = '6aabe8567a227ba7d9aad3e9'; +const tags = [ + { _id: { $oid: gameId }, name: 't5', owner: '6aa1e0ba91f87b7712e8caf8', color: 'Slate' }, + { _id: { $oid: mapId }, name: 'zombie_cod5_sumpf', owner: '6aa1e0ba91f87b7712e8caf8', color: 'Slate' } +]; + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('Komodo tags', () => { + it('reuses the tags from the logged Mongo response and sends string IDs to the deployment', async () => { + fetchMock + .mockResolvedValueOnce(Response.json(tags)) + .mockResolvedValueOnce(Response.json(tags)) + .mockResolvedValueOnce(Response.json({})); + + const game = await komodoEnsureTag('t5'); + const map = await komodoEnsureTag('zombie_cod5_sumpf'); + expect(game).toEqual({ ok: true, id: gameId }); + expect(map).toEqual({ ok: true, id: mapId }); + if (!game.ok || !map.ok) throw new Error('Expected both tags to resolve'); + expect(await komodoUpdateTagsOnResource('Deployment', 't5-sumpf-1', [game.id, map.id])) + .toEqual({ ok: true }); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + 'http://komodo.test/read/ListTags', + 'http://komodo.test/read/ListTags', + 'http://komodo.test/write/UpdateTagsOnResource' + ]); + expect(JSON.parse(fetchMock.mock.calls[2][1]!.body as string)).toEqual({ + target: { type: 'Deployment', id: 't5-sumpf-1' }, tags: [gameId, mapId] + }); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it.each([{ id: gameId }, { _id: gameId }])('preserves string ID support: %j', async (id) => { + fetchMock.mockResolvedValueOnce(Response.json([{ ...id, name: 't5' }])); + expect(await komodoEnsureTag('t5')).toEqual({ ok: true, id: gameId }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('reads an Extended JSON ID from CreateTag without another lookup', async () => { + fetchMock + .mockResolvedValueOnce(Response.json([])) + .mockResolvedValueOnce(Response.json([])) + .mockResolvedValueOnce(Response.json(tags[0])); + expect(await komodoEnsureTag('t5')).toEqual({ ok: true, id: gameId }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[2][0]).toBe('http://komodo.test/write/CreateTag'); + }); + + it('recovers when another caller creates the tag after the initial lookup', async () => { + fetchMock + .mockResolvedValueOnce(Response.json([])) + .mockResolvedValueOnce(Response.json([])) + .mockResolvedValueOnce(Response.json({ error: 'E11000 duplicate key error' }, { status: 500 })) + .mockResolvedValueOnce(Response.json(tags)); + expect(await komodoEnsureTag('t5')).toEqual({ ok: true, id: gameId }); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it.each([null, {}, { $oid: '' }, { $oid: 123 }])('rejects malformed Mongo IDs: %j', async (_id) => { + fetchMock + .mockResolvedValueOnce(Response.json([{ name: 't5', _id }])) + .mockResolvedValueOnce(Response.json({ error: 'create failed' }, { status: 400 })); + expect(await komodoEnsureTag('t5')).toMatchObject({ ok: false }); + }); +});