/** * Unit tests for DynamoDB client request bodies. * * The orchestrator forwards `action` + JSON `body` to the AWS SDK, so these * tests lock the wire params - especially ExclusiveStartKey pagination. */ import { describe, it, expect, vi } from "vitest"; import { z } from "zod"; import type { IntegrationConfig } from "../types.js"; import { DynamoDBClientImpl } from "./client.js"; const TEST_CONFIG: IntegrationConfig = { id: "dynamodb-test-id", name: "Test DynamoDB", pluginId: "dynamodb", configuration: {}, }; const ItemsSchema = z.object({ Items: z.array(z.unknown()).optional() }); function createClient(mockResult: unknown = { Items: [] }) { const executeQuery = vi.fn().mockResolvedValue(mockResult); const client = new DynamoDBClientImpl(TEST_CONFIG, executeQuery); return { client, executeQuery }; } function parsedBody(executeQuery: ReturnType, callIndex = 0) { const request = z .object({ action: z.string(), body: z.string() }) .parse(executeQuery.mock.calls[callIndex]?.[0]); return { action: request.action, body: JSON.parse(request.body) }; } describe("DynamoDBClientImpl", () => { describe("scan", () => { it("omits ExclusiveStartKey on a positional scan", async () => { const { client, executeQuery } = createClient(); await client.scan("retailers", ItemsSchema, "status = :s", { ":s": { S: "active" }, }); expect(parsedBody(executeQuery)).toEqual({ action: "scan", body: { TableName: "retailers", FilterExpression: "status = :s", ExpressionAttributeValues: { ":s": { S: "active" } }, }, }); }); it("forwards ExclusiveStartKey from the options object", async () => { const { client, executeQuery } = createClient(); const exclusiveStartKey = { id: { S: "retailer-18" } }; await client.scan("retailers", ItemsSchema, { exclusiveStartKey }); expect(parsedBody(executeQuery)).toEqual({ action: "scan", body: { TableName: "retailers", ExclusiveStartKey: exclusiveStartKey, }, }); }); it("treats an empty object as empty scan options", async () => { const { client, executeQuery } = createClient(); await client.scan("retailers", ItemsSchema, {}); expect(parsedBody(executeQuery)).toEqual({ action: "scan", body: { TableName: "retailers" }, }); }); it("rejects objects with unknown scan option keys", async () => { const { client, executeQuery } = createClient(); await expect( Reflect.apply(client.scan, client, [ "retailers", ItemsSchema, { label: "not scan options" }, ]), ).rejects.toThrow("Invalid DynamoDB scan options: label"); expect(executeQuery).not.toHaveBeenCalled(); }); it("forwards pagination and parallel-scan options", async () => { const { client, executeQuery } = createClient(); await client.scan( "retailers", ItemsSchema, { exclusiveStartKey: { id: { S: "page-2" } }, limit: 25, projectionExpression: "id, name", indexName: "gsi1", segment: 0, totalSegments: 4, filterExpression: "#s = :s", expressionAttributeNames: { "#s": "status" }, expressionAttributeValues: { ":s": { S: "active" } }, }, { label: "page retailers" }, ); expect(parsedBody(executeQuery)).toEqual({ action: "scan", body: { TableName: "retailers", ExclusiveStartKey: { id: { S: "page-2" } }, Limit: 25, ProjectionExpression: "id, name", IndexName: "gsi1", Segment: 0, TotalSegments: 4, FilterExpression: "#s = :s", ExpressionAttributeNames: { "#s": "status" }, ExpressionAttributeValues: { ":s": { S: "active" } }, }, }); expect(executeQuery).toHaveBeenCalledWith(expect.anything(), undefined, { label: "page retailers", }); }); it("keeps positional names and metadata working together", async () => { const { client, executeQuery } = createClient(); await client.scan( "retailers", ItemsSchema, "#s = :s", { ":s": { S: "active" } }, { "#s": "status" }, { label: "filtered scan" }, ); expect(parsedBody(executeQuery).body).toEqual({ TableName: "retailers", FilterExpression: "#s = :s", ExpressionAttributeValues: { ":s": { S: "active" } }, ExpressionAttributeNames: { "#s": "status" }, }); expect(executeQuery.mock.calls[0][2]).toEqual({ label: "filtered scan" }); }); it("keeps schemas that strip pagination cursors backward compatible", async () => { const lastEvaluatedKey = { id: { S: "retailer-18" }, version: { N: "2" }, }; const { client } = createClient({ Items: [{ id: { S: "retailer-1" } }], LastEvaluatedKey: lastEvaluatedKey, }); await expect( client.scan("retailers", ItemsSchema, { limit: 25 }), ).resolves.toEqual({ Items: [{ id: { S: "retailer-1" } }], }); }); it("preserves mixed AttributeValue pagination cursors", async () => { const lastEvaluatedKey = { id: { S: "retailer-18" }, version: { N: "2" }, }; const PageSchema = z.object({ Items: z.array(z.unknown()).optional(), LastEvaluatedKey: z.record(z.unknown()).optional(), }); const { client } = createClient({ Items: [], LastEvaluatedKey: lastEvaluatedKey, }); const page = await client.scan("retailers", PageSchema, {}); expect(page.LastEvaluatedKey).toEqual(lastEvaluatedKey); }); it("threads each returned pagination cursor into the next request", async () => { const LastEvaluatedKeySchema = z.object({ id: z.object({ S: z.string() }), }); const PageSchema = z.object({ Items: z.array(z.string()), LastEvaluatedKey: LastEvaluatedKeySchema.optional(), }); const lastEvaluatedKey = { id: { S: "retailer-1" } }; const executeQuery = vi .fn() .mockResolvedValueOnce({ Items: ["retailer-1"], LastEvaluatedKey: lastEvaluatedKey, }) .mockResolvedValueOnce({ Items: ["retailer-2"] }); const client = new DynamoDBClientImpl(TEST_CONFIG, executeQuery); const items: string[] = []; let exclusiveStartKey: z.infer | undefined; do { const page = await client.scan("retailers", PageSchema, { exclusiveStartKey, }); items.push(...page.Items); exclusiveStartKey = page.LastEvaluatedKey; } while (exclusiveStartKey); expect(items).toEqual(["retailer-1", "retailer-2"]); expect(executeQuery).toHaveBeenCalledTimes(2); expect(parsedBody(executeQuery, 0).body).toEqual({ TableName: "retailers", }); expect(parsedBody(executeQuery, 1).body).toEqual({ TableName: "retailers", ExclusiveStartKey: lastEvaluatedKey, }); }); }); describe("queryTable", () => { it("treats a plain names map as ExpressionAttributeNames", async () => { const { client, executeQuery } = createClient(); await client.queryTable( "orders", "#uid = :uid", { ":uid": { S: "user-123" } }, ItemsSchema, { "#uid": "userId" }, ); expect(parsedBody(executeQuery)).toEqual({ action: "query", body: { TableName: "orders", KeyConditionExpression: "#uid = :uid", ExpressionAttributeValues: { ":uid": { S: "user-123" } }, ExpressionAttributeNames: { "#uid": "userId" }, }, }); }); }); });