From 5779d1c8ca524c9bced56ba0d57a37da0083868f Mon Sep 17 00:00:00 2001 From: Valamorde <32196694+Valamorde@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:47:44 +0300 Subject: [PATCH] Phase 7: expand test coverage to 43 tests across all handlers and repos Add 23 new tests covering previously untested lambda entry points, message handlers (playerjoin, switch-scene, draw), service actions (getScene, migrate, exportScenes), and the connection repository. --- tests/handlers/messages/draw.test.ts | 38 +++++++ tests/handlers/messages/playerjoin.test.ts | 66 ++++++++++++ tests/handlers/messages/switch-scene.test.ts | 87 ++++++++++++++++ tests/handlers/services/exportScenes.test.ts | 50 +++++++++ tests/handlers/services/getScene.test.ts | 39 +++++++ tests/handlers/services/migrate.test.ts | 73 ++++++++++++++ tests/lambda/keepalive.test.ts | 10 ++ tests/lambda/sendmessage.test.ts | 101 +++++++++++++++++++ tests/repositories/connection.test.ts | 99 ++++++++++++++++++ 9 files changed, 563 insertions(+) create mode 100644 tests/handlers/messages/draw.test.ts create mode 100644 tests/handlers/messages/playerjoin.test.ts create mode 100644 tests/handlers/messages/switch-scene.test.ts create mode 100644 tests/handlers/services/exportScenes.test.ts create mode 100644 tests/handlers/services/getScene.test.ts create mode 100644 tests/handlers/services/migrate.test.ts create mode 100644 tests/lambda/keepalive.test.ts create mode 100644 tests/lambda/sendmessage.test.ts create mode 100644 tests/repositories/connection.test.ts diff --git a/tests/handlers/messages/draw.test.ts b/tests/handlers/messages/draw.test.ts new file mode 100644 index 0000000..58f7563 --- /dev/null +++ b/tests/handlers/messages/draw.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { mockClient } from 'aws-sdk-client-mock'; +import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { drawHandler } from '@/handlers/messages/draw'; +import type { WsEvent, VTTMessage } from '@/shared/types'; + +const ddbMock = mockClient(DynamoDBDocumentClient); +const stubEvent = {} as WsEvent; + +describe('drawHandler', () => { + beforeEach(() => { + ddbMock.reset(); + process.env.TABLE_NAME = 'abovevtt'; + }); + + it('persists draw data with correct objectId key pattern', async () => { + ddbMock.on(PutCommand).resolves({}); + const drawData = [{ x: 0, y: 0, w: 100, h: 100 }]; + const msg: VTTMessage = { + eventType: 'custom/myVTT/drawdata', + campaignId: 'camp1', + cloud: 1, + sceneId: 'scene1', + data: drawData as unknown as Record, + }; + + await drawHandler(stubEvent, msg); + + expect(ddbMock).toHaveReceivedCommandWith(PutCommand, { + TableName: 'abovevtt', + Item: expect.objectContaining({ + campaignId: 'camp1', + objectId: 'scenes#scene1#drawdata', + data: drawData, + }), + }); + }); +}); diff --git a/tests/handlers/messages/playerjoin.test.ts b/tests/handlers/messages/playerjoin.test.ts new file mode 100644 index 0000000..4e6e064 --- /dev/null +++ b/tests/handlers/messages/playerjoin.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { mockClient } from 'aws-sdk-client-mock'; +import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { ApiGatewayManagementApiClient, PostToConnectionCommand } from '@aws-sdk/client-apigatewaymanagementapi'; +import { playerjoinHandler } from '@/handlers/messages/playerjoin'; +import type { WsEvent, VTTMessage } from '@/shared/types'; + +const ddbMock = mockClient(DynamoDBDocumentClient); +const apigwMock = mockClient(ApiGatewayManagementApiClient); + +function makeEvent(connectionId = 'player1'): WsEvent { + return { + body: '{}', + requestContext: { + connectionId, + domainName: 'test.execute-api.us-east-1.amazonaws.com', + stage: 'v1', + routeKey: 'sendmessage', + eventType: 'MESSAGE', + extendedRequestId: 'xxx', + requestTime: '01/Jan/2025:00:00:00 +0000', + messageDirection: 'IN', + connectedAt: 0, + requestTimeEpoch: 0, + requestId: 'xxx', + apiId: 'xxx', + }, + } as unknown as WsEvent; +} + +describe('playerjoinHandler', () => { + beforeEach(() => { + ddbMock.reset(); + apigwMock.reset(); + process.env.TABLE_NAME = 'abovevtt'; + }); + + it('sends fetchscene with the stored player scene id', async () => { + ddbMock.on(GetCommand, { Key: { campaignId: 'camp1', objectId: 'playerscene' } }) + .resolves({ Item: { data: 'scene42' } }); + apigwMock.on(PostToConnectionCommand).resolves({}); + + const msg: VTTMessage = { eventType: 'custom/myVTT/playerjoin', campaignId: 'camp1', cloud: 1 }; + await playerjoinHandler(makeEvent('player1'), msg); + + const calls = apigwMock.commandCalls(PostToConnectionCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input.ConnectionId).toBe('player1'); + const body = JSON.parse(calls[0].args[0].input.Data as string) as Record; + expect(body).toEqual({ eventType: 'custom/myVTT/fetchscene', data: { sceneid: 'scene42' } }); + }); + + it('sends fetchscene with null sceneid when no player scene is set', async () => { + ddbMock.on(GetCommand).resolves({ Item: undefined }); + apigwMock.on(PostToConnectionCommand).resolves({}); + + const msg: VTTMessage = { eventType: 'custom/myVTT/playerjoin', campaignId: 'camp1', cloud: 1 }; + await playerjoinHandler(makeEvent('player1'), msg); + + const calls = apigwMock.commandCalls(PostToConnectionCommand); + expect(calls).toHaveLength(1); + const body = JSON.parse(calls[0].args[0].input.Data as string) as Record; + const data = body.data as Record; + expect(data.sceneid).toBeNull(); + }); +}); diff --git a/tests/handlers/messages/switch-scene.test.ts b/tests/handlers/messages/switch-scene.test.ts new file mode 100644 index 0000000..bb70463 --- /dev/null +++ b/tests/handlers/messages/switch-scene.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { mockClient } from 'aws-sdk-client-mock'; +import { DynamoDBDocumentClient, QueryCommand, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { ApiGatewayManagementApiClient, PostToConnectionCommand } from '@aws-sdk/client-apigatewaymanagementapi'; +import { switchSceneHandler } from '@/handlers/messages/switch-scene'; +import type { WsEvent, VTTMessage } from '@/shared/types'; + +const ddbMock = mockClient(DynamoDBDocumentClient); +const apigwMock = mockClient(ApiGatewayManagementApiClient); + +function makeEvent(connectionId = 'dm1'): WsEvent { + return { + body: '{}', + requestContext: { + connectionId, + domainName: 'test.execute-api.us-east-1.amazonaws.com', + stage: 'v1', + routeKey: 'sendmessage', + eventType: 'MESSAGE', + extendedRequestId: 'xxx', + requestTime: '01/Jan/2025:00:00:00 +0000', + messageDirection: 'IN', + connectedAt: 0, + requestTimeEpoch: 0, + requestId: 'xxx', + apiId: 'xxx', + }, + } as unknown as WsEvent; +} + +describe('switchSceneHandler', () => { + beforeEach(() => { + ddbMock.reset(); + apigwMock.reset(); + process.env.TABLE_NAME = 'abovevtt'; + }); + + it('sends fetchscene to DM connections and stores dmscene when switch_dm=true', async () => { + ddbMock.on(QueryCommand).resolves({ + Items: [{ connectionId: 'dm1', objectId: 'conn#DM#dm1' }], + }); + ddbMock.on(PutCommand).resolves({}); + apigwMock.on(PostToConnectionCommand).resolves({}); + + const msg: VTTMessage = { + eventType: 'custom/myVTT/switch_scene', + campaignId: 'camp1', + cloud: 1, + data: { sceneId: 'scene99', switch_dm: true }, + }; + await switchSceneHandler(makeEvent(), msg); + + expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, { + ExpressionAttributeValues: { ':hkey': 'camp1', ':skey': 'conn#DM#' }, + }); + expect(ddbMock).toHaveReceivedCommandWith(PutCommand, { + Item: expect.objectContaining({ objectId: 'dmscene', data: 'scene99' }), + }); + const apigwCalls = apigwMock.commandCalls(PostToConnectionCommand); + expect(apigwCalls).toHaveLength(1); + const body = JSON.parse(apigwCalls[0].args[0].input.Data as string) as Record; + expect(body).toEqual({ eventType: 'custom/myVTT/fetchscene', data: { sceneid: 'scene99' } }); + }); + + it('sends fetchscene to player connections and stores playerscene when switch_dm=false', async () => { + ddbMock.on(QueryCommand).resolves({ + Items: [{ connectionId: 'p1', objectId: 'conn#PLAYERS#p1' }], + }); + ddbMock.on(PutCommand).resolves({}); + apigwMock.on(PostToConnectionCommand).resolves({}); + + const msg: VTTMessage = { + eventType: 'custom/myVTT/switch_scene', + campaignId: 'camp1', + cloud: 1, + data: { sceneId: 'scene99' }, + }; + await switchSceneHandler(makeEvent(), msg); + + expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, { + ExpressionAttributeValues: { ':hkey': 'camp1', ':skey': 'conn#PLAYERS#' }, + }); + expect(ddbMock).toHaveReceivedCommandWith(PutCommand, { + Item: expect.objectContaining({ objectId: 'playerscene', data: 'scene99' }), + }); + }); +}); diff --git a/tests/handlers/services/exportScenes.test.ts b/tests/handlers/services/exportScenes.test.ts new file mode 100644 index 0000000..a02eb75 --- /dev/null +++ b/tests/handlers/services/exportScenes.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { mockClient } from 'aws-sdk-client-mock'; +import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import { exportScenes } from '@/handlers/services/exportScenes'; +import type { ServiceContext } from '@/shared/types'; + +const ddbMock = mockClient(DynamoDBDocumentClient); + +describe('exportScenes', () => { + beforeEach(() => { + ddbMock.reset(); + process.env.TABLE_NAME = 'abovevtt'; + }); + + it('assembles scenes with tokens, fog, and draw data from sub-items', async () => { + // First query: sceneProperties GSI returns the scene list + ddbMock.on(QueryCommand, { IndexName: 'sceneProperties' }).resolves({ + Items: [{ sceneId: 's1', data: { id: 's1', title: 'Dungeon' } }], + }); + // Second query: per-scene bundle query + ddbMock.on(QueryCommand, { ExpressionAttributeValues: { ':hkey': 'camp1', ':skey': 'scenes#s1' } }).resolves({ + Items: [ + { objectId: 'scenes#s1#scenedata', data: { id: 's1', title: 'Dungeon' } }, + { objectId: 'scenes#s1#fogdata', data: [[0, 0, 1, 1, 1, 0]] }, + { objectId: 'scenes#s1#drawdata', data: [{ x: 1 }] }, + { objectId: 'scenes#s1#tokens#tok1', data: { id: 'tok1', name: 'Orc' } }, + ], + }); + + const ctx: ServiceContext = { campaignId: 'camp1', sceneId: '', body: {} }; + const result = await exportScenes(ctx) as { statusCode: number; body: string }; + + expect(result.statusCode).toBe(200); + const scenes = JSON.parse(result.body) as Array>; + expect(scenes).toHaveLength(1); + const scene = scenes[0]; + expect(scene.reveals).toEqual([[0, 0, 1, 1, 1, 0]]); + expect(scene.drawings).toEqual([{ x: 1 }]); + const tokens = scene.tokens as Record; + expect(tokens['tok1']).toEqual({ id: 'tok1', name: 'Orc' }); + }); + + it('returns empty array when campaign has no scenes', async () => { + ddbMock.on(QueryCommand).resolves({ Items: [] }); + const ctx: ServiceContext = { campaignId: 'camp1', sceneId: '', body: {} }; + const result = await exportScenes(ctx) as { statusCode: number; body: string }; + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body)).toEqual([]); + }); +}); diff --git a/tests/handlers/services/getScene.test.ts b/tests/handlers/services/getScene.test.ts new file mode 100644 index 0000000..46a8176 --- /dev/null +++ b/tests/handlers/services/getScene.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { mockClient } from 'aws-sdk-client-mock'; +import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import { getScene } from '@/handlers/services/getScene'; +import type { ServiceContext } from '@/shared/types'; + +const ddbMock = mockClient(DynamoDBDocumentClient); + +describe('getScene', () => { + beforeEach(() => { + ddbMock.reset(); + process.env.TABLE_NAME = 'abovevtt'; + }); + + it('queries all scene items and assembles the response', async () => { + ddbMock.on(QueryCommand).resolves({ + Items: [ + { objectId: 'scenes#s1#scenedata', data: { id: 's1', title: 'Tavern' } }, + { objectId: 'scenes#s1#fogdata', data: [[0, 0, 1, 1, 1, 0]] }, + { objectId: 'scenes#s1#drawdata', data: [] }, + { objectId: 'scenes#s1#tokens#tok1', data: { id: 'tok1', name: 'Goblin' } }, + ], + }); + + const ctx: ServiceContext = { campaignId: 'c1', sceneId: 's1', body: {} }; + const result = await getScene(ctx) as Record; + + expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, { + ExpressionAttributeValues: { ':hkey': 'c1', ':skey': 'scenes#s1' }, + }); + + const sceneDatum = result['data'] as Record; + expect(sceneDatum.title).toBe('Tavern'); + expect(Array.isArray(sceneDatum.tokens)).toBe(true); + expect((sceneDatum.tokens as unknown[]).length).toBe(1); + expect(sceneDatum.reveals).toEqual([[0, 0, 1, 1, 1, 0]]); + expect(sceneDatum.drawings).toEqual([]); + }); +}); diff --git a/tests/handlers/services/migrate.test.ts b/tests/handlers/services/migrate.test.ts new file mode 100644 index 0000000..fb25ad0 --- /dev/null +++ b/tests/handlers/services/migrate.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { mockClient } from 'aws-sdk-client-mock'; +import { DynamoDBDocumentClient, BatchWriteCommand } from '@aws-sdk/lib-dynamodb'; +import { migrate } from '@/handlers/services/migrate'; +import type { ServiceContext } from '@/shared/types'; + +const ddbMock = mockClient(DynamoDBDocumentClient); + +describe('migrate', () => { + beforeEach(() => { + ddbMock.reset(); + process.env.TABLE_NAME = 'abovevtt'; + }); + + it('batch-writes all scene data and sets cloud flag', async () => { + ddbMock.on(BatchWriteCommand).resolves({}); + + const scenes = [ + { + id: 'scene1', + title: 'Forest', + order: 1_000_000, + reveals: [[0, 0, 1, 1, 1, 0]], + drawings: [], + tokens: { tok1: { id: 'tok1', name: 'Elf' } }, + }, + ]; + + const ctx: ServiceContext = { + campaignId: 'camp1', + sceneId: '', + body: scenes as unknown as Record, + }; + await migrate(ctx); + + expect(ddbMock).toHaveReceivedCommand(BatchWriteCommand); + const allCalls = ddbMock.commandCalls(BatchWriteCommand); + const allItems = allCalls.flatMap(c => + (c.args[0].input.RequestItems?.['abovevtt'] ?? []) + ); + + const objectIds = allItems + .filter(r => 'PutRequest' in r) + .map(r => (r as { PutRequest: { Item: Record } }).PutRequest.Item['objectId'] as string); + + expect(objectIds).toContain('scenes#scene1#scenedata'); + expect(objectIds).toContain('scenes#scene1#fogdata'); + expect(objectIds).toContain('scenes#scene1#drawdata'); + expect(objectIds).toContain('scenes#scene1#tokens#tok1'); + expect(objectIds).toContain('campaigndata'); + }); + + it('assigns generated ids to scenes missing one', async () => { + ddbMock.on(BatchWriteCommand).resolves({}); + + const scenes = [{ title: 'No ID Scene', reveals: [], drawings: [], tokens: {} }]; + const ctx: ServiceContext = { + campaignId: 'camp1', + sceneId: '', + body: scenes as unknown as Record, + }; + await migrate(ctx); + + const allItems = ddbMock.commandCalls(BatchWriteCommand).flatMap(c => + (c.args[0].input.RequestItems?.['abovevtt'] ?? []) + ); + const objectIds = allItems + .filter(r => 'PutRequest' in r) + .map(r => (r as { PutRequest: { Item: Record } }).PutRequest.Item['objectId'] as string); + + expect(objectIds.some(id => id.startsWith('scenes#migrated'))).toBe(true); + }); +}); diff --git a/tests/lambda/keepalive.test.ts b/tests/lambda/keepalive.test.ts new file mode 100644 index 0000000..09a9f16 --- /dev/null +++ b/tests/lambda/keepalive.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { handler } from '@/lambda/keepalive'; +import type { WsEvent } from '@/shared/types'; + +describe('keepalive handler', () => { + it('returns 200 regardless of event content', async () => { + const result = await handler({} as WsEvent); + expect(result).toEqual({ statusCode: 200, body: 'Connected.' }); + }); +}); diff --git a/tests/lambda/sendmessage.test.ts b/tests/lambda/sendmessage.test.ts new file mode 100644 index 0000000..25baa31 --- /dev/null +++ b/tests/lambda/sendmessage.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { mockClient } from 'aws-sdk-client-mock'; +import { DynamoDBDocumentClient, QueryCommand, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { ApiGatewayManagementApiClient, PostToConnectionCommand } from '@aws-sdk/client-apigatewaymanagementapi'; +import { handler } from '@/lambda/sendmessage'; +import type { WsEvent } from '@/shared/types'; + +const ddbMock = mockClient(DynamoDBDocumentClient); +const apigwMock = mockClient(ApiGatewayManagementApiClient); + +function makeEvent(body: object, connectionId = 'conn1'): WsEvent { + return { + body: JSON.stringify(body), + requestContext: { + connectionId, + domainName: 'test.execute-api.us-east-1.amazonaws.com', + stage: 'v1', + routeKey: 'sendmessage', + eventType: 'MESSAGE', + extendedRequestId: 'xxx', + requestTime: '01/Jan/2025:00:00:00 +0000', + messageDirection: 'IN', + connectedAt: 0, + requestTimeEpoch: 0, + requestId: 'xxx', + apiId: 'xxx', + }, + } as unknown as WsEvent; +} + +describe('sendmessage handler', () => { + beforeEach(() => { + ddbMock.reset(); + apigwMock.reset(); + process.env.TABLE_NAME = 'abovevtt'; + }); + + it('returns 200 immediately for keepalive without touching DDB', async () => { + const result = await handler(makeEvent({ eventType: 'custom/myVTT/keepalive', campaignId: 'c1' })); + expect(result).toEqual({ statusCode: 200, body: 'Data sent.' }); + expect(ddbMock.calls()).toHaveLength(0); + }); + + it('forwards a non-cloud message without invoking the domain handler', async () => { + ddbMock.on(QueryCommand).resolves({ Items: [{ connectionId: 'other', objectId: 'conn#PLAYERS#other', timestamp: Date.now() }] }); + apigwMock.on(PostToConnectionCommand).resolves({}); + + const result = await handler(makeEvent({ eventType: 'custom/myVTT/token', campaignId: 'c1', cloud: 0 })); + + expect(result).toEqual({ statusCode: 200, body: 'Data sent.' }); + // PutCommand (token persistence) must NOT be called — cloud=0 skips handler + expect(ddbMock).not.toHaveReceivedCommand(PutCommand); + // PostToConnection IS called (forwarding still happens) + expect(apigwMock).toHaveReceivedCommand(PostToConnectionCommand); + }); + + it('invokes domain handler AND forwards for a cloud token message', async () => { + ddbMock.on(PutCommand).resolves({}); + ddbMock.on(QueryCommand).resolves({ Items: [] }); + apigwMock.on(PostToConnectionCommand).resolves({}); + + const result = await handler(makeEvent({ + eventType: 'custom/myVTT/token', + campaignId: 'c1', + cloud: 1, + sceneId: 's1', + data: { id: 'tok1' }, + })); + + expect(result).toEqual({ statusCode: 200, body: 'Data sent.' }); + // PutCommand called for token persistence + expect(ddbMock).toHaveReceivedCommandWith(PutCommand, { + Item: expect.objectContaining({ objectId: 'scenes#s1#tokens#tok1' }), + }); + }); + + it('suppresses forwarding for switch_scene when cloud=1', async () => { + ddbMock.on(PutCommand).resolves({}); + ddbMock.on(QueryCommand).resolves({ Items: [] }); + apigwMock.on(PostToConnectionCommand).resolves({}); + + await handler(makeEvent({ + eventType: 'custom/myVTT/switch_scene', + campaignId: 'c1', + cloud: 1, + data: { sceneId: 's1', switch_dm: true }, + })); + + // QueryCommand is called by switchSceneHandler (getting DM connections), not by forwardMessage + // We verify the right query — DM type query, not the broad conn# prefix query + expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, { + ExpressionAttributeValues: { ':hkey': 'c1', ':skey': 'conn#DM#' }, + }); + // No broad connection list query (forwardMessage not called) + const queryCalls = ddbMock.commandCalls(QueryCommand); + const broadQuery = queryCalls.find(c => + c.args[0].input.ExpressionAttributeValues?.[':skey'] === 'conn#' + ); + expect(broadQuery).toBeUndefined(); + }); +}); diff --git a/tests/repositories/connection.test.ts b/tests/repositories/connection.test.ts new file mode 100644 index 0000000..630b9e8 --- /dev/null +++ b/tests/repositories/connection.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { mockClient } from 'aws-sdk-client-mock'; +import { DynamoDBDocumentClient, PutCommand, DeleteCommand, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import { connectionRepo } from '@/repositories/connection'; + +const ddbMock = mockClient(DynamoDBDocumentClient); + +describe('connectionRepo', () => { + beforeEach(() => { + ddbMock.reset(); + process.env.TABLE_NAME = 'abovevtt'; + }); + + describe('put', () => { + it('builds conn#DM# objectId for DM connections', async () => { + ddbMock.on(PutCommand).resolves({}); + await connectionRepo.put('camp1', true, 'conn1'); + expect(ddbMock).toHaveReceivedCommandWith(PutCommand, { + Item: expect.objectContaining({ + campaignId: 'camp1', + objectId: 'conn#DM#conn1', + connectionId: 'conn1', + }), + }); + }); + + it('builds conn#PLAYERS# objectId for player connections', async () => { + ddbMock.on(PutCommand).resolves({}); + await connectionRepo.put('camp1', false, 'conn2'); + expect(ddbMock).toHaveReceivedCommandWith(PutCommand, { + Item: expect.objectContaining({ objectId: 'conn#PLAYERS#conn2' }), + }); + }); + + it('writes ttl field to the item', async () => { + ddbMock.on(PutCommand).resolves({}); + const before = Math.floor(Date.now() / 1000); + await connectionRepo.put('camp1', false, 'conn3'); + const after = Math.floor(Date.now() / 1000); + const call = ddbMock.commandCalls(PutCommand)[0]; + const ttl = call.args[0].input.Item?.['ttl'] as number; + expect(ttl).toBeGreaterThanOrEqual(before + 7200); + expect(ttl).toBeLessThanOrEqual(after + 7200); + }); + }); + + describe('deleteById', () => { + it('deletes the correct key', async () => { + ddbMock.on(DeleteCommand).resolves({}); + await connectionRepo.deleteById('camp1', 'conn#DM#conn1'); + expect(ddbMock).toHaveReceivedCommandWith(DeleteCommand, { + Key: { campaignId: 'camp1', objectId: 'conn#DM#conn1' }, + }); + }); + }); + + describe('queryByCampaign', () => { + it('queries with conn# prefix and returns items', async () => { + ddbMock.on(QueryCommand).resolves({ + Items: [{ campaignId: 'camp1', objectId: 'conn#DM#c1', connectionId: 'c1', timestamp: 0, ttl: 0 }], + }); + const result = await connectionRepo.queryByCampaign('camp1'); + expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, { + ExpressionAttributeValues: { ':hkey': 'camp1', ':skey': 'conn#' }, + }); + expect(result).toHaveLength(1); + expect(result[0].connectionId).toBe('c1'); + }); + }); + + describe('queryByType', () => { + it('queries DM connections with conn#DM# prefix', async () => { + ddbMock.on(QueryCommand).resolves({ Items: [] }); + await connectionRepo.queryByType('camp1', true); + expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, { + ExpressionAttributeValues: { ':hkey': 'camp1', ':skey': 'conn#DM#' }, + }); + }); + + it('queries player connections with conn#PLAYERS# prefix', async () => { + ddbMock.on(QueryCommand).resolves({ Items: [] }); + await connectionRepo.queryByType('camp1', false); + expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, { + ExpressionAttributeValues: { ':hkey': 'camp1', ':skey': 'conn#PLAYERS#' }, + }); + }); + }); + + describe('findByConnectionId', () => { + it('queries the connectionIds GSI', async () => { + ddbMock.on(QueryCommand).resolves({ Items: [] }); + await connectionRepo.findByConnectionId('conn99'); + expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, { + IndexName: 'connectionIds', + ExpressionAttributeValues: { ':connectionId': 'conn99' }, + }); + }); + }); +});