@squiz/db-lib 1.75.1 → 1.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/lib/AbstractRepository.js.map +1 -1
- package/lib/ConnectionManager.js.map +1 -1
- package/lib/Migrator.js.map +1 -1
- package/lib/PostgresErrorCodes.js +1 -1
- package/lib/PostgresErrorCodes.js.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.d.ts +4 -2
- package/lib/dynamodb/AbstractDynamoDbRepository.d.ts.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.js +18 -13
- package/lib/dynamodb/AbstractDynamoDbRepository.js.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.d.ts +21 -0
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.d.ts.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.js +126 -15
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.js.map +1 -1
- package/lib/dynamodb/DynamoDbManager.js.map +1 -1
- package/lib/dynamodb/getDynamoDbOptions.d.ts +1 -1
- package/lib/dynamodb/getDynamoDbOptions.d.ts.map +1 -1
- package/lib/dynamodb/getDynamoDbOptions.js.map +1 -1
- package/lib/externalized/ExternalizedDynamoDbRepository.d.ts +151 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.d.ts.map +1 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.js +463 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.js.map +1 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.spec.d.ts +2 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.spec.d.ts.map +1 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.spec.js +218 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.spec.js.map +1 -0
- package/lib/getConnectionInfo.js +1 -2
- package/lib/getConnectionInfo.js.map +1 -1
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -1
- package/lib/s3/S3ExternalStorage.d.ts +66 -0
- package/lib/s3/S3ExternalStorage.d.ts.map +1 -0
- package/lib/s3/S3ExternalStorage.js +84 -0
- package/lib/s3/S3ExternalStorage.js.map +1 -0
- package/lib/s3/S3ExternalStorage.spec.d.ts +12 -0
- package/lib/s3/S3ExternalStorage.spec.d.ts.map +1 -0
- package/lib/s3/S3ExternalStorage.spec.js +130 -0
- package/lib/s3/S3ExternalStorage.spec.js.map +1 -0
- package/package.json +9 -8
- package/src/dynamodb/AbstractDynamoDbRepository.spec.ts +140 -1
- package/src/dynamodb/AbstractDynamoDbRepository.ts +22 -12
- package/src/externalized/ExternalizedDynamoDbRepository.spec.ts +274 -0
- package/src/externalized/ExternalizedDynamoDbRepository.ts +545 -0
- package/src/index.ts +4 -0
- package/src/s3/S3ExternalStorage.spec.ts +181 -0
- package/src/s3/S3ExternalStorage.ts +118 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -21,7 +21,8 @@ import {
|
|
|
21
21
|
} from '@aws-sdk/lib-dynamodb';
|
|
22
22
|
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
|
|
23
23
|
import { DynamoDbManager, Transaction } from './DynamoDbManager';
|
|
24
|
-
import { MissingKeyValuesError
|
|
24
|
+
import { MissingKeyValuesError } from '../error/MissingKeyValuesError';
|
|
25
|
+
import { InvalidDbSchemaError } from '../error/InvalidDbSchemaError';
|
|
25
26
|
|
|
26
27
|
import { mockClient } from 'aws-sdk-client-mock';
|
|
27
28
|
import 'aws-sdk-client-mock-jest';
|
|
@@ -77,8 +78,32 @@ class TestItem implements ITestItem {
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
interface ITestItem2 {
|
|
82
|
+
date: string;
|
|
83
|
+
info: {
|
|
84
|
+
id: string;
|
|
85
|
+
name: string;
|
|
86
|
+
age: number;
|
|
87
|
+
address: {
|
|
88
|
+
country: string;
|
|
89
|
+
city?: string;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
class TestItem2 implements ITestItem2 {
|
|
95
|
+
public date: string;
|
|
96
|
+
public info: ITestItem2['info'];
|
|
97
|
+
|
|
98
|
+
constructor(data: Partial<ITestItem2> = {}) {
|
|
99
|
+
this.date = data.date ?? '';
|
|
100
|
+
this.info = data.info as ITestItem2['info'];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
80
104
|
export type TestRepositories = {
|
|
81
105
|
testItem: TestItemRepository;
|
|
106
|
+
testItem2: TestItem2Repository;
|
|
82
107
|
};
|
|
83
108
|
export type TestDbManager = DynamoDbManager<TestRepositories>;
|
|
84
109
|
|
|
@@ -123,15 +148,49 @@ const TEST_ITEM_ENTITY_DEFINITION = {
|
|
|
123
148
|
optionalIndexes: ['gsi2_pk-gsi2_sk-index'],
|
|
124
149
|
};
|
|
125
150
|
|
|
151
|
+
const TEST_ITEM2_ENTITY_NAME = 'test-item-entity2';
|
|
152
|
+
const TEST_ITEM2_ENTITY_DEFINITION = {
|
|
153
|
+
keys: {
|
|
154
|
+
pk: {
|
|
155
|
+
format: 'c#{info.address.country}',
|
|
156
|
+
attributeName: 'pk',
|
|
157
|
+
},
|
|
158
|
+
sk: {
|
|
159
|
+
format: 'ci#{info.address.city}#{info.id}',
|
|
160
|
+
attributeName: 'sk',
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
indexes: {
|
|
164
|
+
'gsi1_pk-gsi1_sk-index': {
|
|
165
|
+
pk: {
|
|
166
|
+
format: 'dc#{date}#{info.address.country}',
|
|
167
|
+
attributeName: 'gsi1_pk',
|
|
168
|
+
},
|
|
169
|
+
sk: {
|
|
170
|
+
format: '#meta',
|
|
171
|
+
attributeName: 'gsi1_sk',
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
fieldsAsJsonString: [],
|
|
176
|
+
};
|
|
177
|
+
|
|
126
178
|
class TestItemRepository extends AbstractDynamoDbRepository<ITestItem, TestItem> {
|
|
127
179
|
constructor(tableName: string, dbManager: TestDbManager) {
|
|
128
180
|
super(tableName, dbManager, TEST_ITEM_ENTITY_NAME, TEST_ITEM_ENTITY_DEFINITION, TestItem);
|
|
129
181
|
}
|
|
130
182
|
}
|
|
131
183
|
|
|
184
|
+
class TestItem2Repository extends AbstractDynamoDbRepository<ITestItem2, TestItem2> {
|
|
185
|
+
constructor(tableName: string, dbManager: TestDbManager) {
|
|
186
|
+
super(tableName, dbManager, TEST_ITEM2_ENTITY_NAME, TEST_ITEM2_ENTITY_DEFINITION, TestItem2);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
132
190
|
const ddbManager = new DynamoDbManager<TestRepositories>(ddbDoc, (dbManager: TestDbManager) => {
|
|
133
191
|
return {
|
|
134
192
|
testItem: new TestItemRepository(TABLE_NAME, dbManager),
|
|
193
|
+
testItem2: new TestItem2Repository(TABLE_NAME, dbManager),
|
|
135
194
|
};
|
|
136
195
|
});
|
|
137
196
|
|
|
@@ -1658,4 +1717,84 @@ describe('AbstractRepository', () => {
|
|
|
1658
1717
|
});
|
|
1659
1718
|
});
|
|
1660
1719
|
});
|
|
1720
|
+
|
|
1721
|
+
describe('Entity definitions having keys with the object properties', () => {
|
|
1722
|
+
it('should use the correct keys in the request', async () => {
|
|
1723
|
+
const repository = new TestItem2Repository(TABLE_NAME, ddbManager);
|
|
1724
|
+
ddbClientMock.on(PutCommand).resolves({
|
|
1725
|
+
$metadata: {
|
|
1726
|
+
httpStatusCode: 200,
|
|
1727
|
+
},
|
|
1728
|
+
});
|
|
1729
|
+
const input: PutCommandInput = {
|
|
1730
|
+
TableName: TABLE_NAME,
|
|
1731
|
+
Item: {
|
|
1732
|
+
pk: 'c#au',
|
|
1733
|
+
sk: 'ci#syd#id-99',
|
|
1734
|
+
gsi1_pk: 'dc#2025-02-26#au',
|
|
1735
|
+
gsi1_sk: '#meta',
|
|
1736
|
+
|
|
1737
|
+
date: '2025-02-26',
|
|
1738
|
+
info: {
|
|
1739
|
+
id: 'id-99',
|
|
1740
|
+
name: 'john',
|
|
1741
|
+
age: 42,
|
|
1742
|
+
address: {
|
|
1743
|
+
country: 'au',
|
|
1744
|
+
city: 'syd',
|
|
1745
|
+
},
|
|
1746
|
+
},
|
|
1747
|
+
},
|
|
1748
|
+
ConditionExpression: `attribute_not_exists(pk)`,
|
|
1749
|
+
};
|
|
1750
|
+
|
|
1751
|
+
const item = {
|
|
1752
|
+
date: '2025-02-26',
|
|
1753
|
+
info: {
|
|
1754
|
+
id: 'id-99',
|
|
1755
|
+
name: 'john',
|
|
1756
|
+
age: 42,
|
|
1757
|
+
address: {
|
|
1758
|
+
country: 'au',
|
|
1759
|
+
city: 'syd',
|
|
1760
|
+
},
|
|
1761
|
+
},
|
|
1762
|
+
};
|
|
1763
|
+
const result = await repository.createItem(item);
|
|
1764
|
+
expect(ddbClientMock).toHaveReceivedCommandWith(PutCommand, input);
|
|
1765
|
+
expect(result).toEqual(
|
|
1766
|
+
new TestItem2({
|
|
1767
|
+
date: '2025-02-26',
|
|
1768
|
+
info: {
|
|
1769
|
+
id: 'id-99',
|
|
1770
|
+
name: 'john',
|
|
1771
|
+
age: 42,
|
|
1772
|
+
address: {
|
|
1773
|
+
country: 'au',
|
|
1774
|
+
city: 'syd',
|
|
1775
|
+
},
|
|
1776
|
+
},
|
|
1777
|
+
}),
|
|
1778
|
+
);
|
|
1779
|
+
});
|
|
1780
|
+
it('should throw error if input does not includes key field(s)', async () => {
|
|
1781
|
+
const repository = new TestItem2Repository(TABLE_NAME, ddbManager);
|
|
1782
|
+
const partialItem = {
|
|
1783
|
+
date: '2025-02-26',
|
|
1784
|
+
info: {
|
|
1785
|
+
id: 'id-99',
|
|
1786
|
+
name: 'john',
|
|
1787
|
+
age: 42,
|
|
1788
|
+
address: {
|
|
1789
|
+
country: 'au',
|
|
1790
|
+
},
|
|
1791
|
+
},
|
|
1792
|
+
};
|
|
1793
|
+
await expect(repository.getItem(partialItem)).rejects.toEqual(
|
|
1794
|
+
new MissingKeyValuesError(
|
|
1795
|
+
'Key field "info.address.city" must be specified in the input item in entity test-item-entity2',
|
|
1796
|
+
),
|
|
1797
|
+
);
|
|
1798
|
+
});
|
|
1799
|
+
});
|
|
1661
1800
|
});
|
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
BatchGetCommandOutput,
|
|
11
11
|
} from '@aws-sdk/lib-dynamodb';
|
|
12
12
|
|
|
13
|
-
import { Transaction, DynamoDbManager
|
|
13
|
+
import { Transaction, DynamoDbManager } from './DynamoDbManager';
|
|
14
|
+
import { MissingKeyValuesError } from '../error/MissingKeyValuesError';
|
|
15
|
+
import { InvalidDbSchemaError } from '../error/InvalidDbSchemaError';
|
|
14
16
|
import { InvalidDataFormatError } from '../error/InvalidDataFormatError';
|
|
15
17
|
import { UnknownKeyAttributeError } from '../error/UnknownKeyAttributeError';
|
|
16
18
|
|
|
@@ -441,6 +443,7 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
|
441
443
|
value: DATA_CLASS,
|
|
442
444
|
transaction: Transaction = {},
|
|
443
445
|
additionalValue: Partial<SHAPE> = {},
|
|
446
|
+
options: { overrideExisting?: boolean } = { overrideExisting: false },
|
|
444
447
|
): Promise<DATA_CLASS> {
|
|
445
448
|
this.assertValueMatchesModel(value);
|
|
446
449
|
|
|
@@ -475,7 +478,7 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
|
475
478
|
...keyFields,
|
|
476
479
|
...columns,
|
|
477
480
|
},
|
|
478
|
-
ConditionExpression: `attribute_not_exists(${this.keys.pk.attributeName})`,
|
|
481
|
+
ConditionExpression: options.overrideExisting ? undefined : `attribute_not_exists(${this.keys.pk.attributeName})`,
|
|
479
482
|
};
|
|
480
483
|
|
|
481
484
|
if (transaction.id?.length) {
|
|
@@ -620,27 +623,34 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
|
|
|
620
623
|
);
|
|
621
624
|
}
|
|
622
625
|
|
|
623
|
-
const matches = keyFormat.match(/{[a-zA-Z]+?}/g);
|
|
624
|
-
const replacements: { property:
|
|
626
|
+
const matches = keyFormat.match(/{[a-zA-Z\\.]+?}/g);
|
|
627
|
+
const replacements: { property: string; placeholder: string }[] = !matches
|
|
625
628
|
? []
|
|
626
629
|
: matches.map((match) => {
|
|
627
630
|
return {
|
|
628
|
-
property: match.slice(1, -1)
|
|
631
|
+
property: match.slice(1, -1),
|
|
629
632
|
placeholder: match,
|
|
630
633
|
};
|
|
631
634
|
});
|
|
632
635
|
|
|
633
636
|
for (let i = 0; i < replacements.length; i++) {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
637
|
+
let value = JSON.parse(JSON.stringify(item));
|
|
638
|
+
const fields = replacements[i].property.split('.');
|
|
639
|
+
for (let j = 0; j < fields.length; j++) {
|
|
640
|
+
const field = fields[j];
|
|
641
|
+
value = value[field];
|
|
642
|
+
if (value === undefined) {
|
|
643
|
+
throw new MissingKeyValuesError(
|
|
644
|
+
`Key field "${String(replacements[i].property)}" must be specified in the input item in entity ${
|
|
645
|
+
this.entityName
|
|
646
|
+
}`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
639
649
|
}
|
|
640
|
-
keyFormat = keyFormat.replace(replacements[i].placeholder, String(
|
|
650
|
+
keyFormat = keyFormat.replace(replacements[i].placeholder, String(value ?? ''));
|
|
641
651
|
}
|
|
642
652
|
|
|
643
|
-
const moreMatches = keyFormat.match(/{[a-zA-Z]+?}/g);
|
|
653
|
+
const moreMatches = keyFormat.match(/{[a-zA-Z\\.]+?}/g);
|
|
644
654
|
if (moreMatches?.length) {
|
|
645
655
|
throw new MissingKeyValuesError(
|
|
646
656
|
`Cannot resolve key placeholder(s) for key attribute format '${this.keysFormat[attributeName]} in entity ${
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { ExternalizedDynamoDbRepository } from './ExternalizedDynamoDbRepository';
|
|
2
|
+
import { S3ExternalStorage, S3StorageLocation } from '../s3/S3ExternalStorage';
|
|
3
|
+
import { DynamoDbManager } from '../dynamodb/DynamoDbManager';
|
|
4
|
+
import { EntityDefinition } from '../dynamodb/AbstractDynamoDbRepository';
|
|
5
|
+
|
|
6
|
+
// Mock model class for testing
|
|
7
|
+
interface TestItemShape {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
data: Array<{ key: string; value: object }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class TestItem implements TestItemShape {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
data: Array<{ key: string; value: object }>;
|
|
17
|
+
storageLocation?: S3StorageLocation;
|
|
18
|
+
|
|
19
|
+
constructor(input: Record<string, unknown> = {}) {
|
|
20
|
+
this.id = (input.id as string) || '';
|
|
21
|
+
this.name = (input.name as string) || '';
|
|
22
|
+
this.data = (input.data as Array<{ key: string; value: object }>) || [];
|
|
23
|
+
// Note: storageLocation is intentionally not set from input to simulate model stripping it
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Test entity definition
|
|
28
|
+
const testEntityDefinition: EntityDefinition = {
|
|
29
|
+
keys: {
|
|
30
|
+
pk: {
|
|
31
|
+
attributeName: 'pk',
|
|
32
|
+
format: 'ITEM#${id}',
|
|
33
|
+
},
|
|
34
|
+
sk: {
|
|
35
|
+
attributeName: 'sk',
|
|
36
|
+
format: 'ITEM#${id}',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
indexes: {},
|
|
40
|
+
fieldsAsJsonString: ['data'],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Concrete test repository
|
|
44
|
+
class TestRepo extends ExternalizedDynamoDbRepository<TestItemShape, TestItem> {
|
|
45
|
+
public mockSuperUpdateItem?: jest.Mock;
|
|
46
|
+
|
|
47
|
+
constructor(dbManager: DynamoDbManager<any>, storage: S3ExternalStorage, overrides: Partial<EntityDefinition> = {}) {
|
|
48
|
+
super('test-table', dbManager, 'test_item', { ...testEntityDefinition, ...overrides }, TestItem, storage);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Override updateItem to allow mocking super.updateItem
|
|
52
|
+
public async updateItem(value: Partial<TestItemShape>): Promise<TestItem | undefined> {
|
|
53
|
+
if (this.mockSuperUpdateItem) {
|
|
54
|
+
// Use mock if provided
|
|
55
|
+
const previousLocation = await (this as any).fetchStoredLocation(value);
|
|
56
|
+
const updatedItem = await this.mockSuperUpdateItem(value);
|
|
57
|
+
if (!updatedItem) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const newLocation = (updatedItem as any).storageLocation;
|
|
61
|
+
if (previousLocation && previousLocation.key !== newLocation?.key) {
|
|
62
|
+
await (this as any).storage.delete(previousLocation);
|
|
63
|
+
}
|
|
64
|
+
return updatedItem;
|
|
65
|
+
}
|
|
66
|
+
return super.updateItem(value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const createDbManager = (clientOverrides: Record<string, jest.Mock> = {}) =>
|
|
71
|
+
({
|
|
72
|
+
client: {
|
|
73
|
+
get: jest.fn(),
|
|
74
|
+
put: jest.fn(),
|
|
75
|
+
delete: jest.fn(),
|
|
76
|
+
update: jest.fn(),
|
|
77
|
+
...clientOverrides,
|
|
78
|
+
},
|
|
79
|
+
repositories: {} as any,
|
|
80
|
+
executeInTransaction: async <T>(fn: (transaction: any) => Promise<T>): Promise<T> => fn({}),
|
|
81
|
+
addWriteTransactionItem: jest.fn(),
|
|
82
|
+
} as unknown as DynamoDbManager<any>);
|
|
83
|
+
|
|
84
|
+
const createStorage = () =>
|
|
85
|
+
({
|
|
86
|
+
save: jest.fn(),
|
|
87
|
+
load: jest.fn(),
|
|
88
|
+
delete: jest.fn(),
|
|
89
|
+
} as unknown as S3ExternalStorage);
|
|
90
|
+
|
|
91
|
+
const createTestItem = (overrides: Partial<TestItem> = {}): TestItem => {
|
|
92
|
+
const item = new TestItem({
|
|
93
|
+
id: 'item-1',
|
|
94
|
+
name: 'Test',
|
|
95
|
+
data: [{ key: 'sample', value: { main: [] } }],
|
|
96
|
+
...overrides,
|
|
97
|
+
});
|
|
98
|
+
return item;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
describe('ExternalizedDynamoDbRepository', () => {
|
|
102
|
+
it('externalizes data to S3 when prepareValueForStorage is called', async () => {
|
|
103
|
+
const storage = {
|
|
104
|
+
...createStorage(),
|
|
105
|
+
save: jest.fn().mockResolvedValue({ location: { type: 's3', key: 'item-key' }, size: 500 }),
|
|
106
|
+
} as unknown as S3ExternalStorage;
|
|
107
|
+
const repo = new TestRepo(createDbManager(), storage);
|
|
108
|
+
const item = createTestItem();
|
|
109
|
+
|
|
110
|
+
const storedValue = await (repo as any).prepareValueForStorage(item);
|
|
111
|
+
|
|
112
|
+
expect(storage.save).toHaveBeenCalled();
|
|
113
|
+
// storedValue is a minimal payload - original item is NOT mutated
|
|
114
|
+
expect(storedValue.data).toEqual([]);
|
|
115
|
+
expect(storedValue.storageLocation).toEqual({ type: 's3', key: 'item-key' });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('creates item with storageLocation preserved', async () => {
|
|
119
|
+
const client = createDbManager().client as any;
|
|
120
|
+
(client.put as jest.Mock).mockResolvedValue({});
|
|
121
|
+
const storage = {
|
|
122
|
+
...createStorage(),
|
|
123
|
+
save: jest.fn().mockResolvedValue({ location: { type: 's3', key: 'item-key' }, size: 500 }),
|
|
124
|
+
} as unknown as S3ExternalStorage;
|
|
125
|
+
|
|
126
|
+
const repo = new TestRepo({ client } as any, storage);
|
|
127
|
+
const item = createTestItem();
|
|
128
|
+
|
|
129
|
+
// Use prepareValueForStorage to externalize data and set storageLocation
|
|
130
|
+
const preparedItem = await (repo as any).prepareValueForStorage(item);
|
|
131
|
+
|
|
132
|
+
// Verify the prepared value has minimal payload (empty data)
|
|
133
|
+
expect(preparedItem.data).toEqual([]);
|
|
134
|
+
expect(preparedItem.storageLocation).toEqual({ type: 's3', key: 'item-key' });
|
|
135
|
+
|
|
136
|
+
// Original item should NOT be mutated
|
|
137
|
+
expect(item.storageLocation).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('loads payload from external storage when storageLocation is set', async () => {
|
|
141
|
+
const storage = createStorage();
|
|
142
|
+
const fullData = {
|
|
143
|
+
id: 'item-1',
|
|
144
|
+
name: 'From S3',
|
|
145
|
+
data: [{ key: 'sample', value: { main: [] } }],
|
|
146
|
+
};
|
|
147
|
+
(storage.load as jest.Mock).mockResolvedValue(fullData);
|
|
148
|
+
|
|
149
|
+
const repo = new TestRepo(createDbManager({ get: jest.fn() as any }), storage);
|
|
150
|
+
const result = await (repo as any).hydrateFromExternalStorage({
|
|
151
|
+
id: 'item-1',
|
|
152
|
+
name: 'Placeholder',
|
|
153
|
+
data: [],
|
|
154
|
+
storageLocation: { type: 's3', key: 'item-key' },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(storage.load).toHaveBeenCalledWith({ type: 's3', key: 'item-key' });
|
|
158
|
+
expect(result?.name).toBe('From S3');
|
|
159
|
+
expect(result?.data).toEqual([{ key: 'sample', value: { main: [] } }]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('deletes externalized payloads when deleting items', async () => {
|
|
163
|
+
const client = createDbManager({
|
|
164
|
+
get: jest.fn().mockResolvedValue({ Item: { storageLocation: { type: 's3', key: 'item-key' } } }),
|
|
165
|
+
delete: jest.fn().mockResolvedValue({}),
|
|
166
|
+
}).client as any;
|
|
167
|
+
const storage = createStorage();
|
|
168
|
+
const repo = new TestRepo({ client } as any, storage);
|
|
169
|
+
|
|
170
|
+
await repo.deleteItem({ id: 'item-1' });
|
|
171
|
+
|
|
172
|
+
expect(storage.delete).toHaveBeenCalledWith({ type: 's3', key: 'item-key' });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('returns inline data when no storageLocation is present', async () => {
|
|
176
|
+
const storage = createStorage();
|
|
177
|
+
const repo = new TestRepo(createDbManager({ get: jest.fn() as any }), storage);
|
|
178
|
+
const inlineRecord = {
|
|
179
|
+
id: 'item-1',
|
|
180
|
+
name: 'Inline Item',
|
|
181
|
+
data: [{ key: 'sample', value: { main: [] } }],
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const result = await (repo as any).hydrateFromExternalStorage(inlineRecord);
|
|
185
|
+
|
|
186
|
+
expect(storage.load).not.toHaveBeenCalled();
|
|
187
|
+
expect(result).toBeDefined();
|
|
188
|
+
expect(result?.name).toBe('Inline Item');
|
|
189
|
+
expect(result?.data).toEqual([{ key: 'sample', value: { main: [] } }]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('cleans up old S3 file when updating with new storageLocation', async () => {
|
|
193
|
+
const oldLocation = { type: 's3' as const, key: 'old-key' };
|
|
194
|
+
const newLocation = { type: 's3' as const, key: 'new-key' };
|
|
195
|
+
|
|
196
|
+
const client = createDbManager({
|
|
197
|
+
get: jest.fn().mockResolvedValue({
|
|
198
|
+
Item: {
|
|
199
|
+
id: 'item-1',
|
|
200
|
+
name: 'Test',
|
|
201
|
+
storageLocation: oldLocation,
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
update: jest.fn().mockResolvedValue({
|
|
205
|
+
Attributes: {
|
|
206
|
+
id: 'item-1',
|
|
207
|
+
name: 'Updated',
|
|
208
|
+
storageLocation: newLocation,
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
}).client as any;
|
|
212
|
+
|
|
213
|
+
const storage = createStorage();
|
|
214
|
+
const repo = new TestRepo({ client } as any, storage);
|
|
215
|
+
|
|
216
|
+
// Update without storageLocation (it will be fetched from DynamoDB)
|
|
217
|
+
const updateValue = {
|
|
218
|
+
id: 'item-1',
|
|
219
|
+
name: 'Updated',
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Create an updated item with the new storageLocation
|
|
223
|
+
const updatedItem = createTestItem({ id: 'item-1', name: 'Updated' });
|
|
224
|
+
(updatedItem as any).storageLocation = newLocation;
|
|
225
|
+
|
|
226
|
+
// Mock super.updateItem using the custom mock hook
|
|
227
|
+
repo.mockSuperUpdateItem = jest.fn().mockResolvedValue(updatedItem as any);
|
|
228
|
+
|
|
229
|
+
await repo.updateItem(updateValue);
|
|
230
|
+
|
|
231
|
+
expect(storage.delete).toHaveBeenCalledWith(oldLocation);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('does not delete S3 file when storageLocation is unchanged', async () => {
|
|
235
|
+
const location = { type: 's3' as const, key: 'same-key' };
|
|
236
|
+
|
|
237
|
+
const client = createDbManager({
|
|
238
|
+
get: jest.fn().mockResolvedValue({
|
|
239
|
+
Item: {
|
|
240
|
+
id: 'item-1',
|
|
241
|
+
name: 'Test',
|
|
242
|
+
storageLocation: location,
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
update: jest.fn().mockResolvedValue({
|
|
246
|
+
Attributes: {
|
|
247
|
+
id: 'item-1',
|
|
248
|
+
name: 'Updated',
|
|
249
|
+
storageLocation: location,
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
}).client as any;
|
|
253
|
+
|
|
254
|
+
const storage = createStorage();
|
|
255
|
+
const repo = new TestRepo({ client } as any, storage);
|
|
256
|
+
|
|
257
|
+
// Update without storageLocation (it will be fetched from DynamoDB)
|
|
258
|
+
const updateValue = {
|
|
259
|
+
id: 'item-1',
|
|
260
|
+
name: 'Updated',
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Create an updated item with the same storageLocation
|
|
264
|
+
const updatedItem = createTestItem({ id: 'item-1', name: 'Updated' });
|
|
265
|
+
(updatedItem as any).storageLocation = location;
|
|
266
|
+
|
|
267
|
+
// Mock super.updateItem using the custom mock hook
|
|
268
|
+
repo.mockSuperUpdateItem = jest.fn().mockResolvedValue(updatedItem as any);
|
|
269
|
+
|
|
270
|
+
await repo.updateItem(updateValue);
|
|
271
|
+
|
|
272
|
+
expect(storage.delete).not.toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
});
|