Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions tests/handlers/messages/draw.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
};

await drawHandler(stubEvent, msg);

expect(ddbMock).toHaveReceivedCommandWith(PutCommand, {
TableName: 'abovevtt',
Item: expect.objectContaining({
campaignId: 'camp1',
objectId: 'scenes#scene1#drawdata',
data: drawData,
}),
});
});
});
66 changes: 66 additions & 0 deletions tests/handlers/messages/playerjoin.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
const data = body.data as Record<string, unknown>;
expect(data.sceneid).toBeNull();
});
});
87 changes: 87 additions & 0 deletions tests/handlers/messages/switch-scene.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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' }),
});
});
});
50 changes: 50 additions & 0 deletions tests/handlers/services/exportScenes.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>;
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<string, unknown>;
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([]);
});
});
39 changes: 39 additions & 0 deletions tests/handlers/services/getScene.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

expect(ddbMock).toHaveReceivedCommandWith(QueryCommand, {
ExpressionAttributeValues: { ':hkey': 'c1', ':skey': 'scenes#s1' },
});

const sceneDatum = result['data'] as Record<string, unknown>;
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([]);
});
});
73 changes: 73 additions & 0 deletions tests/handlers/services/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
};
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<string, unknown> } }).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<string, unknown>,
};
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<string, unknown> } }).PutRequest.Item['objectId'] as string);

expect(objectIds.some(id => id.startsWith('scenes#migrated'))).toBe(true);
});
});
10 changes: 10 additions & 0 deletions tests/lambda/keepalive.test.ts
Original file line number Diff line number Diff line change
@@ -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.' });
});
});
Loading