second first commit
This commit is contained in:
@@ -0,0 +1,802 @@
|
||||
/**
|
||||
* DynamoDB conformance change tests.
|
||||
*
|
||||
* Covers:
|
||||
* - Phase 1: ListTables (alphabetical order, Limit pagination, Limit validation)
|
||||
* + ValidationException rename for invalid table names
|
||||
* - Phase 2: ProjectionExpression on GetItem, Query, Scan, BatchGetItem
|
||||
* - Phase 3: Batch limits (BatchWrite >25, BatchGet >100), empty PK rejection,
|
||||
* TransactWriteItems duplicate key + idempotency token,
|
||||
* ReturnItemCollectionMetrics=SIZE on LSI table
|
||||
* - Phase 4: Select=COUNT on Query/Scan, parallel scan, attribute_type() function,
|
||||
* parenthesized key condition, missing key condition validation
|
||||
* - Phase 7: TagResource invalid ARN, ListTagsOfResource non-existent ARN
|
||||
* - Phase 8: ReturnValuesOnConditionCheckFailure on PutItem/UpdateItem
|
||||
* - Phase 9: Reserved word rejection in expressions; alias passes
|
||||
* - Phase 10: AttributesToGet, AttributeUpdates, QueryFilter, ScanFilter (legacy API)
|
||||
* - Phase 11: Enum validation fires before table lookup
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
DynamoDBClient,
|
||||
BatchGetItemCommand,
|
||||
BatchWriteItemCommand,
|
||||
CreateTableCommand,
|
||||
DeleteItemCommand,
|
||||
DeleteTableCommand,
|
||||
GetItemCommand,
|
||||
ListTablesCommand,
|
||||
ListTagsOfResourceCommand,
|
||||
PutItemCommand,
|
||||
QueryCommand,
|
||||
ScanCommand,
|
||||
TagResourceCommand,
|
||||
TransactWriteItemsCommand,
|
||||
UpdateItemCommand,
|
||||
} from '@aws-sdk/client-dynamodb';
|
||||
import { makeClient, uniqueName } from './setup';
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function s(v: string) {
|
||||
return { S: v };
|
||||
}
|
||||
function n(v: number) {
|
||||
return { N: String(v) };
|
||||
}
|
||||
|
||||
async function expectError(promise: Promise<unknown>, expectedName: string): Promise<void> {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await promise;
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught, `expected ${expectedName} to be thrown`).toBeDefined();
|
||||
expect((caught as Error).name).toBe(expectedName);
|
||||
}
|
||||
|
||||
// ─── Phase 1: ListTables ────────────────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 1: ListTables', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const tables: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
// Create three tables with deterministic alphabetical names
|
||||
for (const suffix of ['aaa', 'bbb', 'ccc']) {
|
||||
const name = `lt-${suffix}-${uniqueName()}`;
|
||||
tables.push(name);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: name,
|
||||
KeySchema: [{ AttributeName: 'pk', KeyType: 'HASH' }],
|
||||
AttributeDefinitions: [{ AttributeName: 'pk', AttributeType: 'S' }],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
}
|
||||
tables.sort(); // ensure our expectation baseline is sorted
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const t of tables) {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: t })); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
it('returns tables in alphabetical order', async () => {
|
||||
const resp = await ddb.send(new ListTablesCommand({}));
|
||||
const names = resp.TableNames ?? [];
|
||||
const sorted = [...names].sort();
|
||||
expect(names).toEqual(sorted);
|
||||
});
|
||||
|
||||
it('respects Limit and returns LastEvaluatedTableName', async () => {
|
||||
const first = await ddb.send(new ListTablesCommand({ Limit: 1 }));
|
||||
expect(first.TableNames).toHaveLength(1);
|
||||
expect(first.LastEvaluatedTableName).toBeDefined();
|
||||
|
||||
const second = await ddb.send(new ListTablesCommand({
|
||||
Limit: 1,
|
||||
ExclusiveStartTableName: first.LastEvaluatedTableName,
|
||||
}));
|
||||
expect(second.TableNames).toHaveLength(1);
|
||||
expect(second.TableNames![0]).not.toBe(first.TableNames![0]);
|
||||
});
|
||||
|
||||
it('rejects Limit=0 with ValidationException', async () => {
|
||||
await expectError(ddb.send(new ListTablesCommand({ Limit: 0 })), 'ValidationException');
|
||||
});
|
||||
|
||||
it('rejects Limit=101 with ValidationException', async () => {
|
||||
await expectError(ddb.send(new ListTablesCommand({ Limit: 101 })), 'ValidationException');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 1: ValidationException for invalid table names ───────────────────
|
||||
|
||||
describe('DynamoDB — Phase 1: ValidationException rename', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
|
||||
beforeAll(() => { ddb = makeClient(DynamoDBClient); });
|
||||
|
||||
it('rejects a 2-char table name with ValidationException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new PutItemCommand({ TableName: 'ab', Item: { pk: s('x') } })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty table name with ValidationException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new GetItemCommand({ TableName: '', Key: { pk: s('x') } })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 2: ProjectionExpression ──────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 2: ProjectionExpression', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const table = `proj-${uniqueName()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: table,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('p1'), sk: s(`s${i}`), name: s(`Item-${i}`), count: n(i * 10), status: s('active') },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: table })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('GetItem: returns only projected attributes', async () => {
|
||||
const resp = await ddb.send(new GetItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s0') },
|
||||
ProjectionExpression: '#n',
|
||||
ExpressionAttributeNames: { '#n': 'name' },
|
||||
}));
|
||||
expect(resp.Item).toHaveProperty('name');
|
||||
expect(resp.Item).not.toHaveProperty('count');
|
||||
expect(resp.Item).not.toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('Query: returns only projected attributes', async () => {
|
||||
const resp = await ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditionExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': s('p1') },
|
||||
ProjectionExpression: 'sk, #n',
|
||||
ExpressionAttributeNames: { '#n': 'name' },
|
||||
}));
|
||||
expect(resp.Items!.length).toBeGreaterThan(0);
|
||||
for (const item of resp.Items!) {
|
||||
expect(item).toHaveProperty('sk');
|
||||
expect(item).toHaveProperty('name');
|
||||
expect(item).not.toHaveProperty('count');
|
||||
}
|
||||
});
|
||||
|
||||
it('Scan: returns only projected attributes', async () => {
|
||||
const resp = await ddb.send(new ScanCommand({
|
||||
TableName: table,
|
||||
FilterExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': s('p1') },
|
||||
ProjectionExpression: 'pk, sk',
|
||||
}));
|
||||
expect(resp.Items!.length).toBeGreaterThan(0);
|
||||
for (const item of resp.Items!) {
|
||||
expect(item).toHaveProperty('pk');
|
||||
expect(item).toHaveProperty('sk');
|
||||
expect(item).not.toHaveProperty('name');
|
||||
}
|
||||
});
|
||||
|
||||
it('BatchGetItem: returns only projected attributes', async () => {
|
||||
const resp = await ddb.send(new BatchGetItemCommand({
|
||||
RequestItems: {
|
||||
[table]: {
|
||||
Keys: [{ pk: s('p1'), sk: s('s0') }],
|
||||
ProjectionExpression: '#n',
|
||||
ExpressionAttributeNames: { '#n': 'name' },
|
||||
},
|
||||
},
|
||||
}));
|
||||
const items = resp.Responses?.[table] ?? [];
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
expect(items[0]).toHaveProperty('name');
|
||||
expect(items[0]).not.toHaveProperty('count');
|
||||
});
|
||||
|
||||
it('rejects AttributesToGet combined with ProjectionExpression', async () => {
|
||||
await expectError(
|
||||
ddb.send(new GetItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s0') },
|
||||
AttributesToGet: ['name'],
|
||||
ProjectionExpression: 'name',
|
||||
})),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 3: Validation limits ─────────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 3: Batch limits & key validation', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const table = `val-${uniqueName()}`;
|
||||
const lsiTable = `val-lsi-${uniqueName()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: table,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: lsiTable,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
{ AttributeName: 'lsi_sk', AttributeType: 'S' },
|
||||
],
|
||||
LocalSecondaryIndexes: [{
|
||||
IndexName: 'lsi-by-lsi-sk',
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'lsi_sk', KeyType: 'RANGE' },
|
||||
],
|
||||
Projection: { ProjectionType: 'ALL' },
|
||||
}],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: table })); } catch { /* ignore */ }
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: lsiTable })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('BatchWriteItem rejects more than 25 items', async () => {
|
||||
const writes = Array.from({ length: 26 }, (_, i) => ({
|
||||
PutRequest: { Item: { pk: s(`excess-${i}`), sk: s('s') } },
|
||||
}));
|
||||
await expectError(
|
||||
ddb.send(new BatchWriteItemCommand({ RequestItems: { [table]: writes } })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('BatchGetItem rejects more than 100 keys', async () => {
|
||||
const keys = Array.from({ length: 101 }, (_, i) => ({ pk: s(`k${i}`), sk: s('s') }));
|
||||
await expectError(
|
||||
ddb.send(new BatchGetItemCommand({ RequestItems: { [table]: { Keys: keys } } })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('PutItem rejects empty string as PK', async () => {
|
||||
await expectError(
|
||||
ddb.send(new PutItemCommand({ TableName: table, Item: { pk: s(''), sk: s('s') } })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('TransactWriteItems rejects duplicate keys', async () => {
|
||||
await expectError(
|
||||
ddb.send(new TransactWriteItemsCommand({
|
||||
TransactItems: [
|
||||
{ Put: { TableName: table, Item: { pk: s('dup'), sk: s('s') } } },
|
||||
{ Put: { TableName: table, Item: { pk: s('dup'), sk: s('s') } } },
|
||||
],
|
||||
})),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('TransactWriteItems idempotency token conflict throws IdempotentParameterMismatchException', async () => {
|
||||
const token = uniqueName('tok');
|
||||
// First call succeeds
|
||||
await ddb.send(new TransactWriteItemsCommand({
|
||||
ClientRequestToken: token,
|
||||
TransactItems: [{ Put: { TableName: table, Item: { pk: s('idem-node'), sk: s('s') } } }],
|
||||
}));
|
||||
// Same token, different payload
|
||||
await expectError(
|
||||
ddb.send(new TransactWriteItemsCommand({
|
||||
ClientRequestToken: token,
|
||||
TransactItems: [{ Put: { TableName: table, Item: { pk: s('idem-other'), sk: s('s') } } }],
|
||||
})),
|
||||
'IdempotentParameterMismatchException',
|
||||
);
|
||||
});
|
||||
|
||||
it('PutItem with ReturnItemCollectionMetrics=SIZE returns metrics on LSI table', async () => {
|
||||
const resp = await ddb.send(new PutItemCommand({
|
||||
TableName: lsiTable,
|
||||
Item: { pk: s('m1'), sk: s('sk-a'), lsi_sk: s('l1') },
|
||||
ReturnItemCollectionMetrics: 'SIZE',
|
||||
}));
|
||||
expect(resp.ItemCollectionMetrics).toBeDefined();
|
||||
expect(resp.ItemCollectionMetrics!.SizeEstimateRangeGB).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 4: Query/Scan behaviour ──────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 4: Query/Scan behaviour', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const table = `qscan-${uniqueName()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: table,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('p1'), sk: s(`s${i}`), name: s(`Item-${i}`), count: n(i) },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: table })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('Query Select=COUNT returns Count without Items', async () => {
|
||||
const resp = await ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditionExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': s('p1') },
|
||||
Select: 'COUNT',
|
||||
}));
|
||||
expect(resp.Count).toBe(5);
|
||||
expect(resp.Items).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Scan Select=COUNT returns Count without Items', async () => {
|
||||
const resp = await ddb.send(new ScanCommand({
|
||||
TableName: table,
|
||||
FilterExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': s('p1') },
|
||||
Select: 'COUNT',
|
||||
}));
|
||||
expect(resp.Count).toBeGreaterThan(0);
|
||||
expect(resp.Items).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parallel scan partitions with no overlap and full coverage', async () => {
|
||||
const totalSegments = 2;
|
||||
const [seg0, seg1] = await Promise.all([
|
||||
ddb.send(new ScanCommand({ TableName: table, Segment: 0, TotalSegments: totalSegments })),
|
||||
ddb.send(new ScanCommand({ TableName: table, Segment: 1, TotalSegments: totalSegments })),
|
||||
]);
|
||||
|
||||
const keys0 = new Set((seg0.Items ?? []).map(i => i.sk.S!));
|
||||
const keys1 = new Set((seg1.Items ?? []).map(i => i.sk.S!));
|
||||
|
||||
// No overlap
|
||||
for (const k of keys0) expect(keys1.has(k)).toBe(false);
|
||||
// Full coverage
|
||||
expect(keys0.size + keys1.size).toBe(5);
|
||||
});
|
||||
|
||||
it('parallel scan requires both Segment and TotalSegments', async () => {
|
||||
await expectError(
|
||||
ddb.send(new ScanCommand({ TableName: table, Segment: 0 })),
|
||||
'ValidationException',
|
||||
);
|
||||
await expectError(
|
||||
ddb.send(new ScanCommand({ TableName: table, TotalSegments: 2 })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('attribute_type() function in FilterExpression', async () => {
|
||||
const resp = await ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditionExpression: 'pk = :pk',
|
||||
FilterExpression: 'attribute_type(#n, :t)',
|
||||
ExpressionAttributeValues: { ':pk': s('p1'), ':t': s('S') },
|
||||
ExpressionAttributeNames: { '#n': 'name' },
|
||||
}));
|
||||
expect(resp.Count).toBe(5);
|
||||
});
|
||||
|
||||
it('parenthesized key condition expression works', async () => {
|
||||
const resp = await ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditionExpression: '(pk = :pk AND sk = :sk)',
|
||||
ExpressionAttributeValues: { ':pk': s('p1'), ':sk': s('s0') },
|
||||
}));
|
||||
expect(resp.Count).toBe(1);
|
||||
expect(resp.Items![0].sk.S).toBe('s0');
|
||||
});
|
||||
|
||||
it('Query without KeyConditionExpression throws ValidationException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new QueryCommand({ TableName: table })),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 7: Tags API errors ────────────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 7: Tags API errors', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
|
||||
beforeAll(() => { ddb = makeClient(DynamoDBClient); });
|
||||
|
||||
it('TagResource with invalid ARN throws ValidationException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new TagResourceCommand({
|
||||
ResourceArn: 'not-an-arn',
|
||||
Tags: [{ Key: 'k', Value: 'v' }],
|
||||
})),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('ListTagsOfResource with valid-format non-existent ARN throws AccessDeniedException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new ListTagsOfResourceCommand({
|
||||
ResourceArn: 'arn:aws:dynamodb:us-east-1:000000000000:table/does-not-exist',
|
||||
})),
|
||||
'AccessDeniedException',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 8: ReturnValuesOnConditionCheckFailure ────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 8: ReturnValuesOnConditionCheckFailure', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const table = `cond-${uniqueName()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: table,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
await ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('existing'), sk: s('s'), val: s('old') },
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: table })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('PutItem condition failure throws ConditionalCheckFailedException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('existing'), sk: s('s'), val: s('new') },
|
||||
ConditionExpression: 'attribute_not_exists(pk)',
|
||||
ReturnValuesOnConditionCheckFailure: 'ALL_OLD',
|
||||
})),
|
||||
'ConditionalCheckFailedException',
|
||||
);
|
||||
});
|
||||
|
||||
it('UpdateItem condition failure throws ConditionalCheckFailedException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new UpdateItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('existing'), sk: s('s') },
|
||||
UpdateExpression: 'SET val = :new',
|
||||
ConditionExpression: 'val = :expected',
|
||||
ExpressionAttributeValues: { ':new': s('new'), ':expected': s('wrong') },
|
||||
ReturnValuesOnConditionCheckFailure: 'ALL_OLD',
|
||||
})),
|
||||
'ConditionalCheckFailedException',
|
||||
);
|
||||
});
|
||||
|
||||
it('DeleteItem condition failure throws ConditionalCheckFailedException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new DeleteItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('existing'), sk: s('s') },
|
||||
ConditionExpression: 'val = :expected',
|
||||
ExpressionAttributeValues: { ':expected': s('wrong') },
|
||||
ReturnValuesOnConditionCheckFailure: 'ALL_OLD',
|
||||
})),
|
||||
'ConditionalCheckFailedException',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 9: Reserved words ─────────────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 9: Reserved words', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const table = `rw-${uniqueName()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: table,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: table })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('bare reserved word in ConditionExpression throws ValidationException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('rw1'), sk: s('s') },
|
||||
// 'status' is a DynamoDB reserved word
|
||||
ConditionExpression: 'attribute_not_exists(status)',
|
||||
})),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('bare reserved word in FilterExpression throws ValidationException', async () => {
|
||||
await expectError(
|
||||
ddb.send(new ScanCommand({
|
||||
TableName: table,
|
||||
FilterExpression: 'name = :v',
|
||||
ExpressionAttributeValues: { ':v': s('x') },
|
||||
// 'name' is a reserved word used bare
|
||||
})),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
|
||||
it('reserved word aliased via ExpressionAttributeNames passes', async () => {
|
||||
// Using #st alias for the reserved word 'status'. The alias must be referenced
|
||||
// by the expression: AWS rejects ExpressionAttributeNames entries left unused.
|
||||
await ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('rw-ok'), sk: s('s'), status: s('active') },
|
||||
ConditionExpression: 'attribute_not_exists(#st)',
|
||||
ExpressionAttributeNames: { '#st': 'status' },
|
||||
}));
|
||||
|
||||
const resp = await ddb.send(new ScanCommand({
|
||||
TableName: table,
|
||||
FilterExpression: '#st = :v',
|
||||
ExpressionAttributeNames: { '#st': 'status' },
|
||||
ExpressionAttributeValues: { ':v': s('active') },
|
||||
}));
|
||||
expect(resp.Count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 10: Legacy API ────────────────────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 10: Legacy API', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
const table = `legacy-${uniqueName()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
ddb = makeClient(DynamoDBClient);
|
||||
await ddb.send(new CreateTableCommand({
|
||||
TableName: table,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' },
|
||||
],
|
||||
BillingMode: 'PAY_PER_REQUEST',
|
||||
}));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await ddb.send(new PutItemCommand({
|
||||
TableName: table,
|
||||
Item: { pk: s('p1'), sk: s(`s${i}`), name: s(`Item-${i}`), color: s('red') },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await ddb.send(new DeleteTableCommand({ TableName: table })); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('AttributesToGet returns only listed attributes (no auto-key-include)', async () => {
|
||||
const resp = await ddb.send(new GetItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s0') },
|
||||
AttributesToGet: ['name', 'color'],
|
||||
}));
|
||||
expect(resp.Item).toHaveProperty('name');
|
||||
expect(resp.Item).toHaveProperty('color');
|
||||
expect(resp.Item).not.toHaveProperty('pk');
|
||||
expect(resp.Item).not.toHaveProperty('sk');
|
||||
});
|
||||
|
||||
it('AttributeUpdates (legacy) applies PUT action', async () => {
|
||||
await ddb.send(new UpdateItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s0') },
|
||||
AttributeUpdates: {
|
||||
color: { Value: s('blue'), Action: 'PUT' },
|
||||
},
|
||||
}));
|
||||
const resp = await ddb.send(new GetItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s0') },
|
||||
}));
|
||||
expect(resp.Item!.color.S).toBe('blue');
|
||||
});
|
||||
|
||||
it('AttributeUpdates (legacy) applies DELETE action to remove attribute', async () => {
|
||||
await ddb.send(new UpdateItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s1') },
|
||||
AttributeUpdates: {
|
||||
color: { Action: 'DELETE' },
|
||||
},
|
||||
}));
|
||||
const resp = await ddb.send(new GetItemCommand({
|
||||
TableName: table,
|
||||
Key: { pk: s('p1'), sk: s('s1') },
|
||||
}));
|
||||
expect(resp.Item).not.toHaveProperty('color');
|
||||
});
|
||||
|
||||
it('QueryFilter (legacy) filters query results', async () => {
|
||||
const resp = await ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditions: {
|
||||
pk: { ComparisonOperator: 'EQ', AttributeValueList: [s('p1')] },
|
||||
},
|
||||
QueryFilter: {
|
||||
name: { ComparisonOperator: 'EQ', AttributeValueList: [s('Item-0')] },
|
||||
},
|
||||
}));
|
||||
expect(resp.Count).toBe(1);
|
||||
expect(resp.Items![0].name.S).toBe('Item-0');
|
||||
});
|
||||
|
||||
it('QueryFilter and KeyConditionExpression are mutually exclusive', async () => {
|
||||
const err = await ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditionExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': s('p1') },
|
||||
QueryFilter: {
|
||||
name: { ComparisonOperator: 'EQ', AttributeValueList: [s('Item-0')] },
|
||||
},
|
||||
})).catch(e => e);
|
||||
|
||||
expect(err.name).toBe('ValidationException');
|
||||
expect(err.message).toBe(
|
||||
'Can not use both expression and non-expression parameters in the same request: '
|
||||
+ 'Non-expression parameters: {QueryFilter} Expression parameters: {KeyConditionExpression}',
|
||||
);
|
||||
});
|
||||
|
||||
it('ScanFilter (legacy) filters scan results', async () => {
|
||||
const resp = await ddb.send(new ScanCommand({
|
||||
TableName: table,
|
||||
ScanFilter: {
|
||||
name: { ComparisonOperator: 'EQ', AttributeValueList: [s('Item-2')] },
|
||||
},
|
||||
}));
|
||||
expect(resp.Count).toBe(1);
|
||||
expect(resp.Items![0].name.S).toBe('Item-2');
|
||||
});
|
||||
|
||||
it('KeyConditions and KeyConditionExpression are mutually exclusive', async () => {
|
||||
await expectError(
|
||||
ddb.send(new QueryCommand({
|
||||
TableName: table,
|
||||
KeyConditionExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': s('p1') },
|
||||
KeyConditions: { pk: { ComparisonOperator: 'EQ', AttributeValueList: [s('p1')] } },
|
||||
})),
|
||||
'ValidationException',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 11: Enum validation ordering ──────────────────────────────────────
|
||||
|
||||
describe('DynamoDB — Phase 11: Enum validation before table lookup', () => {
|
||||
let ddb: DynamoDBClient;
|
||||
|
||||
beforeAll(() => { ddb = makeClient(DynamoDBClient); });
|
||||
|
||||
it('invalid ReturnValues on PutItem throws ValidationException even for non-existent table', async () => {
|
||||
const err = await ddb.send(new PutItemCommand({
|
||||
TableName: 'nonexistent-table-xyz',
|
||||
Item: { pk: s('x') },
|
||||
ReturnValues: 'UPDATED_NEW', // invalid for PutItem
|
||||
})).catch(e => e);
|
||||
|
||||
expect(err.name).toBe('ValidationException');
|
||||
// Must NOT be ResourceNotFoundException
|
||||
expect(err.name).not.toBe('ResourceNotFoundException');
|
||||
});
|
||||
|
||||
it('invalid ReturnValues on DeleteItem throws ValidationException even for non-existent table', async () => {
|
||||
const err = await ddb.send(new DeleteItemCommand({
|
||||
TableName: 'nonexistent-table-xyz',
|
||||
Key: { pk: s('x'), sk: s('y') },
|
||||
ReturnValues: 'UPDATED_NEW', // invalid for DeleteItem
|
||||
})).catch(e => e);
|
||||
|
||||
expect(err.name).toBe('ValidationException');
|
||||
});
|
||||
|
||||
it('invalid ReturnConsumedCapacity throws ValidationException even for non-existent table', async () => {
|
||||
const err = await ddb.send(new GetItemCommand({
|
||||
TableName: 'nonexistent-table-xyz',
|
||||
Key: { pk: s('x'), sk: s('y') },
|
||||
// @ts-expect-error invalid enum value
|
||||
ReturnConsumedCapacity: 'INVALID_VALUE',
|
||||
})).catch(e => e);
|
||||
|
||||
expect(err.name).toBe('ValidationException');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user