Skip to content

Commit 9299143

Browse files
committed
CCM-11600: supplier repository and table
1 parent 153c9b2 commit 9299143

File tree

6 files changed

+267
-2
lines changed

6 files changed

+267
-2
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
resource "aws_dynamodb_table" "suppliers" {
2+
name = "${local.csi}-suppliers"
3+
billing_mode = "PAY_PER_REQUEST"
4+
5+
hash_key = "id"
6+
range_key = "apimId"
7+
8+
ttl {
9+
attribute_name = "ttl"
10+
enabled = false
11+
}
12+
13+
global_secondary_index {
14+
name = "supplier-apim-index"
15+
hash_key = "apimId"
16+
projection_type = "ALL"
17+
}
18+
19+
attribute {
20+
name = "id"
21+
type = "S"
22+
}
23+
24+
attribute {
25+
name = "apimId"
26+
type = "S"
27+
}
28+
29+
point_in_time_recovery {
30+
enabled = true
31+
}
32+
33+
tags = var.default_tags
34+
}

internal/datastore/src/__test__/db.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export async function setupDynamoDBContainer() {
3131
endpoint,
3232
lettersTableName: 'letters',
3333
miTableName: 'management-info',
34+
suppliersTableName: 'suppliers',
3435
lettersTtlHours: 1,
3536
miTtlHours: 1
3637
};
@@ -94,6 +95,29 @@ const createMITableCommand = new CreateTableCommand({
9495
]
9596
});
9697

98+
const createSupplierTableCommand = new CreateTableCommand({
99+
TableName: 'suppliers',
100+
BillingMode: 'PAY_PER_REQUEST',
101+
KeySchema: [
102+
{ AttributeName: 'id', KeyType: 'HASH' } // Partition key
103+
],
104+
GlobalSecondaryIndexes: [
105+
{
106+
IndexName: 'supplier-apim-index',
107+
KeySchema: [
108+
{ AttributeName: 'apimId', KeyType: 'HASH' } // Partition key for GSI
109+
],
110+
Projection: {
111+
ProjectionType: 'ALL'
112+
}
113+
}
114+
],
115+
AttributeDefinitions: [
116+
{ AttributeName: 'id', AttributeType: 'S' },
117+
{ AttributeName: 'apimId', AttributeType: 'S' }
118+
]
119+
});
120+
97121

98122
export async function createTables(context: DBContext) {
99123
const { ddbClient } = context;
@@ -102,6 +126,7 @@ export async function createTables(context: DBContext) {
102126
await ddbClient.send(updateTimeToLiveCommand);
103127

104128
await ddbClient.send(createMITableCommand);
129+
await ddbClient.send(createSupplierTableCommand);
105130
}
106131

107132

@@ -115,4 +140,8 @@ export async function deleteTables(context: DBContext) {
115140
await ddbClient.send(new DeleteTableCommand({
116141
TableName: 'management-info'
117142
}));
143+
144+
await ddbClient.send(new DeleteTableCommand({
145+
TableName: 'suppliers'
146+
}));
118147
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { Logger } from "pino";
2+
import { createTables, DBContext, deleteTables, setupDynamoDBContainer } from "./db";
3+
import { createTestLogger, LogStream } from "./logs";
4+
import { SupplierRepository } from "../supplier-repository";
5+
import { Supplier } from "../types";
6+
import { v4 as uuidv4 } from 'uuid';
7+
8+
function createSupplier(status: 'ENABLED' | 'DISABLED', apimId = uuidv4()): Omit<Supplier, 'updatedAt'> {
9+
return {
10+
id: uuidv4(),
11+
name: 'Supplier One',
12+
apimId,
13+
status
14+
}
15+
}
16+
17+
// Database tests can take longer, especially with setup and teardown
18+
jest.setTimeout(30000);
19+
20+
describe('SupplierRepository', () => {
21+
let db: DBContext;
22+
let supplierRepository: SupplierRepository;
23+
let logStream: LogStream;
24+
let logger: Logger;
25+
26+
beforeAll(async () => {
27+
db = await setupDynamoDBContainer();
28+
});
29+
30+
beforeEach(async () => {
31+
await createTables(db);
32+
(
33+
{ logStream, logger } = createTestLogger()
34+
);
35+
36+
supplierRepository = new SupplierRepository(db.docClient, logger, db.config);
37+
});
38+
39+
afterEach(async () => {
40+
await deleteTables(db);
41+
jest.useRealTimers();
42+
});
43+
44+
afterAll(async () => {
45+
await db.container.stop();
46+
});
47+
48+
test('creates an enabled supplier with provided values and timestamps', async () => {
49+
jest.useFakeTimers();
50+
// Month is zero-indexed in JS Date
51+
jest.setSystemTime(new Date(2020, 1, 1));
52+
53+
const supplier = createSupplier('ENABLED');
54+
55+
const persistedSupplier = await supplierRepository.putSupplier(supplier);
56+
57+
expect(persistedSupplier).toEqual(expect.objectContaining({
58+
...supplier,
59+
updatedAt: '2020-02-01T00:00:00.000Z',
60+
}));
61+
});
62+
63+
test('fetches a supplier by its ID', async () => {
64+
const supplier = createSupplier('DISABLED')
65+
await supplierRepository.putSupplier(supplier);
66+
67+
const fetched = await supplierRepository.getSupplierById(supplier.id);
68+
69+
expect(fetched).toEqual(expect.objectContaining({
70+
...supplier
71+
}));
72+
});
73+
74+
test('throws an error fetching a supplier that does not exist', async () => {
75+
await expect(supplierRepository.getSupplierById('non-existent-id'))
76+
.rejects.toThrow('Supplier with id non-existent-id not found');
77+
});
78+
79+
test('overwrites an existing supplier entry', async () => {
80+
const supplier = createSupplier('DISABLED');
81+
82+
const original = await supplierRepository.putSupplier(supplier);
83+
expect(original.status).toBe('DISABLED');
84+
85+
supplier.status = 'ENABLED';
86+
const updated = await supplierRepository.putSupplier(supplier);
87+
expect(updated.status).toBe('ENABLED');
88+
});
89+
90+
test('rethrows errors from DynamoDB when creating a letter', async () => {
91+
const misconfiguredRepository = new SupplierRepository(db.docClient, logger, {
92+
...db.config,
93+
suppliersTableName: 'nonexistent-table'
94+
});
95+
await expect(misconfiguredRepository.putSupplier(createSupplier('ENABLED')))
96+
.rejects.toThrow('Cannot do operations on a non-existent table');
97+
});
98+
99+
test('fetches a supplier by apimId', async () => {
100+
const supplier = createSupplier('ENABLED');
101+
102+
await supplierRepository.putSupplier(supplier);
103+
104+
const fetched = await supplierRepository.getSupplierByApimId(supplier.apimId);
105+
expect(fetched).toEqual(expect.objectContaining({
106+
...supplier
107+
}));
108+
});
109+
110+
test('throws an error fetching a supplier by apimId that does not exist', async () => {
111+
await expect(supplierRepository.getSupplierByApimId('non-existent-apim-id'))
112+
.rejects.toThrow('Supplier with apimId non-existent-apim-id not found');
113+
});
114+
115+
test('throws an error fetching a supplier by apimId when multiple exist', async () => {
116+
const apimId = 'duplicate-apim-id';
117+
const supplier1 = createSupplier('ENABLED', apimId);
118+
const supplier2 = createSupplier('DISABLED', apimId);
119+
120+
await supplierRepository.putSupplier(supplier1);
121+
await supplierRepository.putSupplier(supplier2);
122+
123+
await expect(supplierRepository.getSupplierByApimId(apimId))
124+
.rejects.toThrow(`Multiple suppliers found with apimId ${apimId}`);
125+
});
126+
127+
});

internal/datastore/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export type DatastoreConfig = {
33
endpoint?: string,
44
lettersTableName: string,
55
miTableName: string,
6+
suppliersTableName: string,
67
lettersTtlHours: number,
78
miTtlHours: number
89
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {
2+
DynamoDBDocumentClient,
3+
GetCommand,
4+
PutCommand,
5+
QueryCommand
6+
} from '@aws-sdk/lib-dynamodb';
7+
import { Supplier, SupplierSchema } from './types';
8+
import { Logger } from 'pino';
9+
import { v4 as uuidv4 } from 'uuid';
10+
import z from 'zod';
11+
12+
export type SupplierRepositoryConfig = {
13+
suppliersTableName: string
14+
};
15+
16+
export class SupplierRepository {
17+
constructor(readonly ddbClient: DynamoDBDocumentClient,
18+
readonly log: Logger,
19+
readonly config: SupplierRepositoryConfig) {
20+
}
21+
22+
async putSupplier(supplier: Omit<Supplier, 'updatedAt'>): Promise<Supplier> {
23+
24+
const now = new Date().toISOString();
25+
const supplierDb = {
26+
...supplier,
27+
updatedAt: now
28+
};
29+
30+
await this.ddbClient.send(new PutCommand({
31+
TableName: this.config.suppliersTableName,
32+
Item: supplierDb,
33+
}));
34+
35+
return SupplierSchema.parse(supplierDb);
36+
}
37+
38+
async getSupplierById(supplierId: string): Promise<Supplier> {
39+
const result = await this.ddbClient.send(new GetCommand({
40+
TableName: this.config.suppliersTableName,
41+
Key: {
42+
id: supplierId
43+
}
44+
}));
45+
46+
if (!result.Item) {
47+
throw new Error(`Supplier with id ${supplierId} not found`);
48+
}
49+
50+
return SupplierSchema.parse(result.Item);
51+
}
52+
53+
async getSupplierByApimId(apimId: string): Promise<Supplier> {
54+
const result = await this.ddbClient.send(new QueryCommand({
55+
TableName: this.config.suppliersTableName,
56+
IndexName: 'supplier-apim-index',
57+
KeyConditionExpression: 'apimId = :apimId',
58+
ExpressionAttributeValues: {
59+
':apimId': apimId
60+
},
61+
}));
62+
63+
if(result.Count && result.Count > 1) {
64+
throw new Error(`Multiple suppliers found with apimId ${apimId}`);
65+
}
66+
67+
if(result.Count === 0 || !result.Items) {
68+
throw new Error(`Supplier with apimId ${apimId} not found`);
69+
}
70+
71+
return SupplierSchema.parse(result.Items[0]);
72+
}
73+
};

internal/datastore/src/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { z } from 'zod';
22
import { idRef } from '@internal/helpers';
33

4-
export const SupplerStatus = z.enum(['ENABLED', 'DISABLED']);
4+
export const SupplierStatus = z.enum(['ENABLED', 'DISABLED']);
55

66
export const SupplierSchema = z.object({
77
id: z.string(),
88
name: z.string(),
99
apimId: z.string(),
10-
status: SupplerStatus
10+
status: SupplierStatus,
11+
updatedAt: z.string(),
1112
}).describe('Supplier');
1213

1314
export type Supplier = z.infer<typeof SupplierSchema>;

0 commit comments

Comments
 (0)