diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-07-02 11:55:17 -0700 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-07-02 11:55:17 -0700 |
| commit | 6bf4b90c90f15f4ab60833bddf5b5756d1a6b1f6 (patch) | |
| tree | ed97e39ec77c5231ffd2c394493e68d00ddac5a4 /Homework/cs5260/consumer | |
| download | misc-undergrad-main.tar.gz misc-undergrad-main.zip | |
Diffstat (limited to 'Homework/cs5260/consumer')
24 files changed, 1488 insertions, 0 deletions
diff --git a/Homework/cs5260/consumer/Loop.ts b/Homework/cs5260/consumer/Loop.ts new file mode 100644 index 0000000..e8e88d2 --- /dev/null +++ b/Homework/cs5260/consumer/Loop.ts @@ -0,0 +1,47 @@ +import { TracingLogger } from "./log"; +import { WidgetRequestProcessor } from "./processor"; +import { WidgetRequestRetriever } from "./retrievers"; +import { WidgetRequestT } from "./schema"; + +const sleepFor = (ms: number) => new Promise((res) => setTimeout(res, ms)); + +export const MainLoop = async ( + retrievers: WidgetRequestRetriever[], + processors: WidgetRequestProcessor[], + maxPollAttempts: number, + pollIntervalMs: number, + logger: TracingLogger, +) => { + let pollAttemptsWithNoResults = 0; + while (pollAttemptsWithNoResults < maxPollAttempts) { + logger.info(`polling...`); + + let requests: WidgetRequestT[] = []; + for (const retriever of retrievers) { + const retrievalRequests = await retriever.refreshRequests(); + requests = requests.concat(retrievalRequests); + } + logger.info(`received Requests=(${requests.length}) requests...`); + + for (const request of requests) { + for (const processor of processors) { + await processor.processRequest(request); + } + } + logger.info( + `finished processing Requests=(${requests.length}) requests...`, + ); + + if (requests.length === 0) { + pollAttemptsWithNoResults++; + logger.info( + `sleeping for Interval=(${pollIntervalMs})ms: (${pollAttemptsWithNoResults} / ${maxPollAttempts})`, + ); + await sleepFor(pollIntervalMs); + } else { + pollAttemptsWithNoResults = 0; + } + } + + logger.info(`no requests after Attempts=(${maxPollAttempts})...`); +}; diff --git a/Homework/cs5260/consumer/args.ts b/Homework/cs5260/consumer/args.ts new file mode 100644 index 0000000..0b9a0bd --- /dev/null +++ b/Homework/cs5260/consumer/args.ts @@ -0,0 +1,13 @@ +import parseArgs from "minimist"; + +export type Args = { + _: any; + storagetable?: string; + storagebucket?: string; + storageprefix?: string; + + sourcebucket?: string; + sourcequeue?: string; +}; + +export const args = parseArgs(process.argv.slice(2)) as Args; diff --git a/Homework/cs5260/consumer/dao/DynamoWidgetDAO.ts b/Homework/cs5260/consumer/dao/DynamoWidgetDAO.ts new file mode 100644 index 0000000..a90788f --- /dev/null +++ b/Homework/cs5260/consumer/dao/DynamoWidgetDAO.ts @@ -0,0 +1,100 @@ +import { Widget, WidgetT } from "@/shared/schema"; +import { WidgetDAO } from "."; +import { TracingLogger } from "@/consumer/log"; +import { + AttributeValue, + DeleteItemCommand, + DynamoDBClient, + GetItemCommand, + PutItemCommand, +} from "@aws-sdk/client-dynamodb"; + +export class DynamoWidgetDAO implements WidgetDAO { + private table: string; + private ddbClient: DynamoDBClient; + private logger: TracingLogger; + + constructor(table: string, ddbClient: DynamoDBClient, logger: TracingLogger) { + this.table = table; + this.ddbClient = ddbClient; + this.logger = logger; + } + + public async retrieve(widget: WidgetT) { + const getReq = new GetItemCommand({ + TableName: this.table, + Key: { + id: { + S: widget.id, + }, + }, + }); + + const response = await this.ddbClient.send(getReq); + if (response.Item) { + this.logger.info(`sucessfully retrieved Widget=(${widget.id})`); + return this.deflate(response.Item); + } + } + + public async save(widget: WidgetT) { + const putReq = new PutItemCommand({ + TableName: this.table, + Item: this.inflate(widget), + }); + + await this.ddbClient.send(putReq); + + this.logger.info(`sucessfully saved Widget=(${widget.id})`); + return widget; + } + + public async delete(widget: WidgetT) { + const deleteReq = new DeleteItemCommand({ + TableName: this.table, + Key: { + id: { S: widget.id }, + }, + }); + + await this.ddbClient.send(deleteReq); + + this.logger.info(`sucessfully deleted Widget=(${widget.id})`); + return widget; + } + + private inflate(widget: WidgetT): Record<string, AttributeValue> { + const additionalAttributes = + widget.otherAttributes?.reduce((acc, { name, value }) => { + acc[name] = { S: value }; + return acc; + }, {} as Record<string, { S: string }>) ?? {}; + if (widget.label) additionalAttributes["label"] = { S: widget.label }; + if (widget.description) + additionalAttributes["description"] = { S: widget.description }; + + return { + id: { S: widget.id }, + owner: { S: widget.owner }, + ...additionalAttributes, + }; + } + + private deflate(item: Record<string, AttributeValue>): WidgetT { + const respWidget = Object.keys(item).reduce((widge, key: string) => { + const attributeValue = item![key]; + if (["id", "owner", "label", "description"].includes(key)) { + widge[key] = attributeValue.S; + return widge; + } + + const attribute = { name: key, value: attributeValue.S! }; + widge["otherAttributes"] = widge["otherAttributes"]?.concat([ + attribute, + ]) ?? [attribute]; + + return widge; + }, {} as Record<string, any>); + return Widget.parse(respWidget); + } +} diff --git a/Homework/cs5260/consumer/dao/S3WidgetDao.ts b/Homework/cs5260/consumer/dao/S3WidgetDao.ts new file mode 100644 index 0000000..d5aee72 --- /dev/null +++ b/Homework/cs5260/consumer/dao/S3WidgetDao.ts @@ -0,0 +1,93 @@ +import { WidgetT, Widget } from "@/shared/schema"; +import { WidgetDAO } from "."; +import { + DeleteObjectCommand, + GetObjectCommand, + NoSuchKey, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { S3WidgetKeyGenerator } from "./keys"; +import { TracingLogger } from "@/consumer/log"; + +export class S3WidgetDAO implements WidgetDAO { + private bucket: string; + private s3Client: S3Client; + private widgetKeyGenerator: S3WidgetKeyGenerator; + private logger: TracingLogger; + + constructor( + bucket: string, + s3Client: S3Client, + widgetKeyGenerator: S3WidgetKeyGenerator, + logger: TracingLogger + ) { + this.bucket = bucket; + this.s3Client = s3Client; + this.widgetKeyGenerator = widgetKeyGenerator; + this.logger = logger; + } + + public async retrieve(widget: WidgetT) { + const key = this.widgetKeyGenerator.fromWidget(widget); + + this.logger.info( + `getting Widget=(${widget.id}) in Bucket=(${this.bucket})` + ); + + const retrievalRequest = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + + try { + const objectResp = await this.s3Client.send(retrievalRequest); + const textRepr = await objectResp.Body?.transformToString(); + + if (textRepr) { + this.logger.info(`retrieved WidgetObject=(${widget.id}) from S3`); + return Widget.parse(JSON.parse(textRepr)); + } + } catch (e: unknown) { + if (e instanceof NoSuchKey) { + return; + } + throw e; + } + } + + public async save(widget: WidgetT) { + const key = this.widgetKeyGenerator.fromWidget(widget); + + this.logger.info(`saving Widget=(${widget.id}) in Bucket=(${this.bucket})`); + + const putReq = new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: JSON.stringify(widget, null, 2), + }); + + await this.s3Client.send(putReq); + + this.logger.info(`successfully saved Widget=(${widget.id}) in S3`); + return widget; + } + + public async delete(widget: WidgetT) { + const key = this.widgetKeyGenerator.fromWidget(widget); + + this.logger.info( + `deleting Widget=(${widget.id}) in Bucket=(${this.bucket})` + ); + + const deleteReq = new DeleteObjectCommand({ + Key: key, + Bucket: this.bucket, + }); + + await this.s3Client.send(deleteReq); + + this.logger.info(`successfully deleted Widget=(${widget.id}) in S3`); + return widget; + } +} diff --git a/Homework/cs5260/consumer/dao/WidgetDAO.ts b/Homework/cs5260/consumer/dao/WidgetDAO.ts new file mode 100644 index 0000000..2b7f3d6 --- /dev/null +++ b/Homework/cs5260/consumer/dao/WidgetDAO.ts @@ -0,0 +1,7 @@ +import { WidgetT } from "@/shared/schema"; + +export interface WidgetDAO { + retrieve(widget: WidgetT): Promise<WidgetT | undefined>; + save(widget: WidgetT): Promise<WidgetT>; + delete(widget: WidgetT): Promise<WidgetT>; +} diff --git a/Homework/cs5260/consumer/dao/index.ts b/Homework/cs5260/consumer/dao/index.ts new file mode 100644 index 0000000..82e158c --- /dev/null +++ b/Homework/cs5260/consumer/dao/index.ts @@ -0,0 +1,3 @@ +export * from "./WidgetDAO"; +export * from "./S3WidgetDao"; +export * from "./DynamoWidgetDAO"; diff --git a/Homework/cs5260/consumer/dao/keys/S3WidgetKeyGenerator.ts b/Homework/cs5260/consumer/dao/keys/S3WidgetKeyGenerator.ts new file mode 100644 index 0000000..2e4c254 --- /dev/null +++ b/Homework/cs5260/consumer/dao/keys/S3WidgetKeyGenerator.ts @@ -0,0 +1,43 @@ +import { WidgetT } from "@/shared/schema"; + +export class S3WidgetKeyGenerator { + private prefix: string = ""; + private owner: string = ""; + private widgetId: string = ""; + + public withPrefix(prefix: string): S3WidgetKeyGenerator { + this.prefix = prefix; + return this; + } + + public withOwner(owner: string): S3WidgetKeyGenerator { + this.owner = owner; + return this; + } + + public withWidgetId(widgetId: string): S3WidgetKeyGenerator { + this.widgetId = widgetId; + return this; + } + + public build(): string { + const parts = [ + this.prefix, + this.owner.replaceAll(" ", "-").toLowerCase(), + this.widgetId, + ]; + if (parts.some((part) => !part || !part.match(/^[a-zA-Z0-9!\-_.*'()]+$/))) { + throw new Error("Invalid Key Specification"); + } + return parts.join("/"); + } + + public fromWidget(widget: WidgetT) { + const keyBuilder = new S3WidgetKeyGenerator(); + return keyBuilder + .withPrefix(this.prefix) + .withOwner(widget.owner) + .withWidgetId(widget.id) + .build(); + } +} diff --git a/Homework/cs5260/consumer/dao/keys/index.ts b/Homework/cs5260/consumer/dao/keys/index.ts new file mode 100644 index 0000000..c7c8467 --- /dev/null +++ b/Homework/cs5260/consumer/dao/keys/index.ts @@ -0,0 +1 @@ +export * from "./S3WidgetKeyGenerator"; diff --git a/Homework/cs5260/consumer/index.ts b/Homework/cs5260/consumer/index.ts new file mode 100644 index 0000000..f8ce640 --- /dev/null +++ b/Homework/cs5260/consumer/index.ts @@ -0,0 +1,113 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { SQSClient } from "@aws-sdk/client-sqs"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; + +import { Args, args } from "@/consumer/args"; +import { DynamoWidgetDAO, S3WidgetDAO } from "@/consumer/dao"; +import { S3WidgetKeyGenerator } from "@/consumer/dao/keys"; +import { ConsoleTracingLogger } from "@/shared/log"; +import { ConsumerRequestProcessor, WidgetRequestProcessor } from "@/consumer/processor"; +import { + S3WidgetRequestRetriever, + WidgetRequestRetriever, + SQSWidgetRequestRetriever, +} from "@/consumer/retrievers"; +import { MainLoop } from "@/consumer/Loop"; + +const main = async (args: Args) => { + const mainLogger = new ConsoleTracingLogger("main"); + + const s3Client = new S3Client(); + const sqsClient = new SQSClient(); + const dynamoClient = new DynamoDBClient(); + + const retrievers: WidgetRequestRetriever[] = []; + if (!args.sourcebucket && !args.sourcequeue) { + throw new Error("Must specify source to poll."); + } + + if (args.sourcebucket) { + mainLogger.info( + `constructing s3 retriever for Bucket=(${args.sourcebucket})`, + ); + + const retriever = new S3WidgetRequestRetriever( + args.sourcebucket, + s3Client, + mainLogger.createChild("S3WidgetRequestRetriever"), + ); + retrievers.push(retriever); + } + + if (args.sourcequeue) { + mainLogger.info( + `constructing sqs retriever for Bucket=(${args.sourcequeue})`, + ); + + const retriever = new SQSWidgetRequestRetriever( + args.sourcequeue, + sqsClient, + mainLogger.createChild("SQSWidgetRequestRetriever"), + ); + retrievers.push(retriever); + } + + if (!args.storagebucket && !args.storagetable) { + throw new Error("Must specify a target data store for Widgets"); + } + + const processors: WidgetRequestProcessor[] = []; + + if (args.storagebucket) { + if (!args.storageprefix) { + throw new Error("must specify widget prefix location for storage bucket"); + } + + mainLogger.info( + `constructing s3 processor for Bucket=(${args.storagebucket})`, + ); + + const s3KeyGenerator = new S3WidgetKeyGenerator().withPrefix( + args.storageprefix, + ); + const s3WidgetDao = new S3WidgetDAO( + args.storagebucket, + s3Client, + s3KeyGenerator, + mainLogger.createChild("S3WidgetDAO"), + ); + + const s3RequestProcessor = new ConsumerRequestProcessor( + s3WidgetDao, + mainLogger.createChild("ConsumerRequestProcessor => (S3)"), + ); + + processors.push(s3RequestProcessor); + } + + if (args.storagetable) { + mainLogger.info( + `constructing dynamo processor for Table=(${args.storagetable})`, + ); + + const dynamoWidgetDao = new DynamoWidgetDAO( + args.storagetable, + dynamoClient, + mainLogger.createChild("DynamoWidgetDAO"), + ); + + const dynamoRequestProcessor = new ConsumerRequestProcessor( + dynamoWidgetDao, + mainLogger.createChild("ConsumerRequestProcessor => (Dynamo)"), + ); + + processors.push(dynamoRequestProcessor); + } + + const maxPollAttempts = 15; + await MainLoop(retrievers, processors, maxPollAttempts, 200, mainLogger); + + mainLogger.info("EXITING!"); +}; + +main(args); diff --git a/Homework/cs5260/consumer/processor/ConsumerRequestProcessor.ts b/Homework/cs5260/consumer/processor/ConsumerRequestProcessor.ts new file mode 100644 index 0000000..87e66c1 --- /dev/null +++ b/Homework/cs5260/consumer/processor/ConsumerRequestProcessor.ts @@ -0,0 +1,133 @@ +import { WidgetDAO } from "@/consumer/dao"; +import { TracingLogger } from "@/consumer/log"; +import { Widget, WidgetT, WidgetRequestT } from "@/shared/schema"; + +export class ConsumerRequestProcessor implements WidgetRequestProcessor { + private widgetDao: WidgetDAO; + private logger: TracingLogger; + + constructor(widgetDao: WidgetDAO, logger: TracingLogger) { + this.widgetDao = widgetDao; + this.logger = logger; + } + + public async processRequest(widgetRequest: WidgetRequestT) { + this.logger.info( + `processing RequestId=(${widgetRequest.requestId}) with WidgetRequestType=(${widgetRequest.type})` + ); + switch (widgetRequest.type) { + case "create": + await this.createWidget(widgetRequest); + break; + case "delete": + await this.deleteWidget(widgetRequest); + break; + case "update": + await this.updateWidget(widgetRequest); + break; + default: + this.logger.warn( + `received WidgetRequestType=(${widgetRequest.type})... don't know what to do!` + ); + break; + } + } + + private async createWidget(widgetRequest: WidgetRequestT) { + this.logger.info(`creating new Widget=(${widgetRequest.widgetId})`); + const widget = this.widgetFromWidgetRequest(widgetRequest); + + const currentWidget = await this.widgetDao.retrieve(widget); + + if (!currentWidget) { + await this.widgetDao.save(widget); + } else { + this.logger.warn( + `received creation request for Widget=(${widget.id}) but it already exists... skipping` + ); + } + } + + private async deleteWidget(widgetRequest: WidgetRequestT) { + this.logger.info(`deleting Widget=(${widgetRequest.widgetId})`); + const widget = this.widgetFromWidgetRequest(widgetRequest); + + const currentWidget = await this.widgetDao.retrieve(widget); + + if (currentWidget) { + await this.widgetDao.delete(widget); + } else { + this.logger.warn( + `received deletion request for Widget=(${widget.id}) but it did not exist... skipping` + ); + } + } + + private async updateWidget(widgetRequest: WidgetRequestT) { + this.logger.info(`updating Widget=(${widgetRequest.widgetId})`); + const widget = this.widgetFromWidgetRequest(widgetRequest); + + const currentWidget = await this.widgetDao.retrieve(widget); + if (!currentWidget) { + this.logger.warn( + `attempted to retrieve Widget=(${widgetRequest.widgetId}) to update, but it doesn't exist! skipping...` + ); + return; + } + + const immutableKeys = ["id", "owner"] as unknown as (keyof WidgetT)[]; + + for (const key of Object.keys(widget) as unknown as (keyof WidgetT)[]) { + const isImmutable = immutableKeys.includes(key); + if (!isImmutable && widget[key] === "") { + delete widget[key]; + delete currentWidget[key]; + } else if (isImmutable && widget[key] !== currentWidget[key]) { + this.logger.warn( + `cannot change ${key} on Widget=(${widget.id}). skipping...` + ); + return; + } + } + + const otherAttributeMap = new Map<string, string>(); + [ + ...(currentWidget.otherAttributes ?? []), + ...(widget.otherAttributes ?? []), + ].forEach(({ name, value }) => { + if (value != null) otherAttributeMap.set(name, value); + if (value == "") otherAttributeMap.delete(name); + }); + const otherAttributes = Array.from(otherAttributeMap.entries()).map( + ([name, value]) => { + return { name, value }; + } + ); + + const newWidget: WidgetT = { + ...currentWidget, + ...widget, + }; + + if (otherAttributes.length) { + newWidget.otherAttributes = otherAttributes; + } + + await this.widgetDao.save(newWidget); + } + + private widgetFromWidgetRequest(widgetRequest: WidgetRequestT): WidgetT { + const widget: Record<string, any> = { + id: widgetRequest.widgetId, + owner: widgetRequest.owner, + }; + + if (widgetRequest.label !== undefined) widget.label = widgetRequest.label; + if (widgetRequest.description !== undefined) + widget.description = widgetRequest.description; + if (widgetRequest.otherAttributes?.length) + widget.otherAttributes = widgetRequest.otherAttributes; + + return Widget.parse(widget); + } +} diff --git a/Homework/cs5260/consumer/processor/WidgetRequestProcessor.ts b/Homework/cs5260/consumer/processor/WidgetRequestProcessor.ts new file mode 100644 index 0000000..fbb96ac --- /dev/null +++ b/Homework/cs5260/consumer/processor/WidgetRequestProcessor.ts @@ -0,0 +1,5 @@ +import { WidgetRequestT } from "@/shared/schema"; + +export interface WidgetRequestProcessor { + processRequest(widgetRequest: WidgetRequestT): Promise<void>; +} diff --git a/Homework/cs5260/consumer/processor/index.ts b/Homework/cs5260/consumer/processor/index.ts new file mode 100644 index 0000000..983da1b --- /dev/null +++ b/Homework/cs5260/consumer/processor/index.ts @@ -0,0 +1,2 @@ +export * from "./ConsumerRequestProcessor"; +export * from "./WidgetRequestProcessor"; diff --git a/Homework/cs5260/consumer/retrievers/S3WidgetRequestRetriever.ts b/Homework/cs5260/consumer/retrievers/S3WidgetRequestRetriever.ts new file mode 100644 index 0000000..786e0a6 --- /dev/null +++ b/Homework/cs5260/consumer/retrievers/S3WidgetRequestRetriever.ts @@ -0,0 +1,64 @@ +import { + DeleteObjectCommand, + GetObjectCommand, + ListObjectsV2Command, + S3Client, +} from "@aws-sdk/client-s3"; +import { WidgetRequestRetriever } from "."; +import { WidgetRequest } from "@/shared/schema"; +import { TracingLogger } from "@/consumer/log"; + +export class S3WidgetRequestRetriever implements WidgetRequestRetriever { + static LIST_KEY_MAX = 1; + private bucket: string; + private s3Client: S3Client; + private logger: TracingLogger; + + constructor(bucket: string, s3Client: S3Client, logger: TracingLogger) { + this.bucket = bucket; + this.s3Client = s3Client; + this.logger = logger; + } + + private async getWidgetRequest(key: string) { + const getCommand = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + + const response = await this.s3Client.send(getCommand); + const body = await response.Body?.transformToString(); + + if (body && body.length) return WidgetRequest.parse(JSON.parse(body)); + } + + public async refreshRequests() { + // ListObjectsV2 returns keys in ascending lexicographical order + const listCommand = new ListObjectsV2Command({ + MaxKeys: S3WidgetRequestRetriever.LIST_KEY_MAX, + Bucket: this.bucket, + }); + const response = await this.s3Client.send(listCommand); + + const key = response.Contents && response.Contents[0]?.Key; + + if (!key) { + this.logger.info(`No more keys in Bucket=(${this.bucket}).`); + return []; + } + + const widgetRequest = await this.getWidgetRequest(key); + + const deleteWidgetRequest = new DeleteObjectCommand({ + Key: key, + Bucket: this.bucket, + }); + await this.s3Client.send(deleteWidgetRequest); + + if (!widgetRequest) { + this.logger.warn(`No valid widget at Key=(${key}).`); + return []; + } + return [widgetRequest]; + } +} diff --git a/Homework/cs5260/consumer/retrievers/SQSWidgetRequestRetriever.ts b/Homework/cs5260/consumer/retrievers/SQSWidgetRequestRetriever.ts new file mode 100644 index 0000000..40a837b --- /dev/null +++ b/Homework/cs5260/consumer/retrievers/SQSWidgetRequestRetriever.ts @@ -0,0 +1,60 @@ +import { + DeleteMessageBatchCommand, + ReceiveMessageCommand, + SQSClient, +} from "@aws-sdk/client-sqs"; +import { WidgetRequestRetriever } from "."; +import { TracingLogger } from "@/shared/log"; +import { WidgetRequest, WidgetRequestT } from "@/shared/schema"; + +export class SQSWidgetRequestRetriever implements WidgetRequestRetriever { + static MESSAGES_PER_POLL = 10; + private queueUrl: string; + private sqsClient: SQSClient; + private logger: TracingLogger; + + constructor(queueUrl: string, sqsClient: SQSClient, logger: TracingLogger) { + this.queueUrl = queueUrl; + this.sqsClient = sqsClient; + this.logger = logger; + } + + public async refreshRequests() { + const retrieveMessagesRequest = new ReceiveMessageCommand({ + QueueUrl: this.queueUrl, + MaxNumberOfMessages: SQSWidgetRequestRetriever.MESSAGES_PER_POLL, + }); + + this.logger.info( + `looking for Messages=(${SQSWidgetRequestRetriever.MESSAGES_PER_POLL}) WidgetRequest's from Queue=(${this.queueUrl})`, + ); + const response = await this.sqsClient.send(retrieveMessagesRequest); + if (!response.Messages) { + this.logger.info(`Queue=(${this.queueUrl}) returned no messages!`); + return []; + } + + const widgetRequests = response.Messages.map((message) => { + if (message.Body) return WidgetRequest.parse(JSON.parse(message.Body)); + }).filter((x) => x) as WidgetRequestT[]; + this.logger.info( + `found WidgetRequests=(${widgetRequests.length}) requests`, + ); + + const messageDeleteInputs = response.Messages.map( + ({ ReceiptHandle, MessageId }) => { + return { Id: MessageId, ReceiptHandle }; + }, + ); + this.logger.info( + `requesting deletion of Inputs=(${messageDeleteInputs.length})`, + ); + const deleteRequestCommand = new DeleteMessageBatchCommand({ + QueueUrl: this.queueUrl, + Entries: messageDeleteInputs, + }); + await this.sqsClient.send(deleteRequestCommand); + + return widgetRequests; + } +} diff --git a/Homework/cs5260/consumer/retrievers/WidgetRequestRetriever.ts b/Homework/cs5260/consumer/retrievers/WidgetRequestRetriever.ts new file mode 100644 index 0000000..f5f5775 --- /dev/null +++ b/Homework/cs5260/consumer/retrievers/WidgetRequestRetriever.ts @@ -0,0 +1,6 @@ +import { WidgetRequestT } from "@/shared/schema"; + +export interface WidgetRequestRetriever { + /* get new requests and delete them */ + refreshRequests(): Promise<WidgetRequestT[]>; +} diff --git a/Homework/cs5260/consumer/retrievers/index.ts b/Homework/cs5260/consumer/retrievers/index.ts new file mode 100644 index 0000000..1bfc014 --- /dev/null +++ b/Homework/cs5260/consumer/retrievers/index.ts @@ -0,0 +1,3 @@ +export * from "./WidgetRequestRetriever"; +export * from "./S3WidgetRequestRetriever"; +export * from "./SQSWidgetRequestRetriever"; diff --git a/Homework/cs5260/consumer/t/ConsumerRequestProcessor.spec.ts b/Homework/cs5260/consumer/t/ConsumerRequestProcessor.spec.ts new file mode 100644 index 0000000..28d785b --- /dev/null +++ b/Homework/cs5260/consumer/t/ConsumerRequestProcessor.spec.ts @@ -0,0 +1,200 @@ +import { randomUUID } from "node:crypto"; +import { Widget, WidgetRequest, WidgetT } from "@/shared/schema"; +import { expect, mock, test } from "bun:test"; +import { ConsumerRequestProcessor } from "@/consumer/processor"; +import { VoidLogger } from "@/shared/t"; + +const widgetDAOFactory = (): any => { + return { + retrieve: mock(async (widget: WidgetT) => { + return widget; + }), + save: mock(async (widget: WidgetT) => { + return widget; + }), + delete: mock(async (widget: WidgetT) => { + return widget; + }), + }; +}; + +test("when create request then warns when already existant and not save", async () => { + const logger = new VoidLogger(); + logger.warn = mock((_s) => {}); + + const mockWidgetDAO = widgetDAOFactory(); + mockWidgetDAO.retrieve = mock(async (widget: WidgetT) => widget); + + const newWidgetRequest = WidgetRequest.parse({ + type: "create", + requestId: randomUUID(), + owner: "simponic", + widgetId: randomUUID(), + }); + const processor = new ConsumerRequestProcessor(mockWidgetDAO, logger); + + await processor.processRequest(newWidgetRequest); + + expect(mockWidgetDAO.retrieve).toHaveBeenCalledTimes(1); + expect(mockWidgetDAO.save).toHaveBeenCalledTimes(0); + expect(logger.warn).toHaveBeenCalledTimes(1); +}); + +test("when create request then saves in dao if not exist", async () => { + const logger = new VoidLogger(); + const mockWidgetDAO = widgetDAOFactory(); + mockWidgetDAO.retrieve = mock(async (_widget: WidgetT) => { + return undefined; + }); + + const newWidgetRequest = WidgetRequest.parse({ + type: "create", + requestId: randomUUID(), + owner: "simponic", + widgetId: randomUUID(), + }); + const processor = new ConsumerRequestProcessor(mockWidgetDAO, logger); + + await processor.processRequest(newWidgetRequest); + + expect(mockWidgetDAO.retrieve).toHaveBeenCalledTimes(1); + expect(mockWidgetDAO.save).toHaveBeenCalledTimes(1); +}); + +test("when delete request then fails and warns if not exists", async () => { + const logger = new VoidLogger(); + logger.warn = mock((_s) => {}); + + const mockWidgetDAO = widgetDAOFactory(); + mockWidgetDAO.retrieve = mock(async (_widget: WidgetT) => { + return undefined; + }); + + const deleteWidgetRequest = WidgetRequest.parse({ + type: "delete", + requestId: randomUUID(), + owner: "simponic", + widgetId: randomUUID(), + }); + const processor = new ConsumerRequestProcessor(mockWidgetDAO, logger); + + await processor.processRequest(deleteWidgetRequest); + + expect(mockWidgetDAO.retrieve).toHaveBeenCalledTimes(1); + expect(mockWidgetDAO.delete).toHaveBeenCalledTimes(0); + expect(logger.warn).toHaveBeenCalledTimes(1); +}); + +test("when delete request then success if exists", async () => { + const logger = new VoidLogger(); + + const mockWidgetDAO = widgetDAOFactory(); + mockWidgetDAO.retrieve = mock(async (widget: WidgetT) => { + return widget; + }); + + const deleteWidgetRequest = WidgetRequest.parse({ + type: "delete", + requestId: randomUUID(), + owner: "simponic", + widgetId: randomUUID(), + }); + const processor = new ConsumerRequestProcessor(mockWidgetDAO, logger); + + await processor.processRequest(deleteWidgetRequest); + + expect(mockWidgetDAO.retrieve).toHaveBeenCalledTimes(1); + expect(mockWidgetDAO.delete).toHaveBeenCalledTimes(1); +}); + +test("when update request then deletes empty string values", async () => { + const logger = new VoidLogger(); + + const testWidget = Widget.parse({ + id: "test-widget", + owner: "simponic", + label: "current label", + }); + const mockWidgetDAO = widgetDAOFactory(); + mockWidgetDAO.retrieve = mock(async (_widget: WidgetT) => testWidget); + + const updateWidgetRequest = WidgetRequest.parse({ + type: "update", + requestId: randomUUID(), + owner: "simponic", + widgetId: testWidget.id, + label: "", + }); + + const processor = new ConsumerRequestProcessor(mockWidgetDAO, logger); + + await processor.processRequest(updateWidgetRequest); + + expect(mockWidgetDAO.retrieve).toHaveBeenCalledTimes(1); + expect(mockWidgetDAO.save).toHaveBeenCalledTimes(1); + expect(mockWidgetDAO.save.mock.calls[0][0]).toEqual( + Widget.parse({ id: "test-widget", owner: "simponic" }), // "label" has been removed + ); +}); + +test("when update attempts to overwrite owner then update fails and warns", async () => { + const logger = new VoidLogger(); + logger.warn = mock((_s) => {}); + + const testWidget = Widget.parse({ + id: "test-widget", + owner: "simponic", + label: "current label", + }); + + const mockWidgetDAO = widgetDAOFactory(); + mockWidgetDAO.retrieve = mock(async (_widget: WidgetT) => testWidget); + + const newWidgetRequest = WidgetRequest.parse({ + type: "update", + requestId: randomUUID(), + owner: "newOwner", + widgetId: testWidget.id, + }); + const processor = new ConsumerRequestProcessor(mockWidgetDAO, logger); + + await processor.processRequest(newWidgetRequest); + + expect(mockWidgetDAO.retrieve).toHaveBeenCalledTimes(1); + expect(mockWidgetDAO.save).toHaveBeenCalledTimes(0); + expect(logger.warn).toHaveBeenCalledTimes(1); +}); + +test("when update mutable attribute field unspecified then not overwritten", async () => { + const logger = new VoidLogger(); + + const testWidget = Widget.parse({ + id: "test-widget", + owner: "simponic", + label: "non-overwritten label", + }); + + const mockWidgetDAO = widgetDAOFactory(); + mockWidgetDAO.retrieve = mock(async (_widget: WidgetT) => testWidget); + + const newWidgetRequest = WidgetRequest.parse({ + type: "update", + requestId: randomUUID(), + owner: testWidget.owner, + widgetId: testWidget.id, + description: "", + }); + const processor = new ConsumerRequestProcessor(mockWidgetDAO, logger); + + await processor.processRequest(newWidgetRequest); + + expect(mockWidgetDAO.retrieve).toHaveBeenCalledTimes(1); + expect(mockWidgetDAO.save).toHaveBeenCalledTimes(1); + expect(mockWidgetDAO.save.mock.calls[0][0]).toEqual( + Widget.parse({ + id: "test-widget", + owner: "simponic", + label: "non-overwritten label", + }), + ); +}); diff --git a/Homework/cs5260/consumer/t/DynamoWidgetDAO.spec.ts b/Homework/cs5260/consumer/t/DynamoWidgetDAO.spec.ts new file mode 100644 index 0000000..01ea185 --- /dev/null +++ b/Homework/cs5260/consumer/t/DynamoWidgetDAO.spec.ts @@ -0,0 +1,117 @@ +import { Widget } from "@/shared/schema"; +import { expect, test, mock } from "bun:test"; +import { + DeleteItemCommand, + DynamoDBClient, + GetItemCommand, + PutItemCommand, +} from "@aws-sdk/client-dynamodb"; +import { DynamoWidgetDAO } from "@/consumer/dao"; +import { VoidLogger } from "@/shared/t"; + +const testTable = "widgets-t"; +const testWidget = Widget.parse({ + id: "testWidgetId", + owner: "simponic", + label: "A Widget", + description: "Nothing to see here!", + otherAttributes: [{ name: "length", value: "20cm" }], +}); + +const mockGetItemSend = mock(async (req: GetItemCommand) => { + if (req.input.Key!.id.S! === testWidget.id) { + return { + Item: { + id: { S: testWidget.id }, + owner: { S: testWidget.owner }, + label: { S: testWidget.label }, + description: { S: testWidget.description }, + length: { S: "20cm" }, + }, + }; + } + return {}; +}); + +const expectMockPutItem = (field: string, value: string) => + mock(async (req: PutItemCommand) => { + if (req.input.Item![field].S === value) { + return { Attributes: true }; + } + throw new Error("invalid field"); + }); + +const mockDeleteItemSuccessful = mock(async (req: DeleteItemCommand) => { + if (req.input!.Key!.id.S === testWidget.id) { + return { $metadata: true }; + } +}); + +test("when retrieve dynamo dao item then inflates and returns retrieved item", async () => { + const mockClient = { send: mockGetItemSend }; + const logger = new VoidLogger(); + const dao = new DynamoWidgetDAO( + testTable, + mockClient as unknown as DynamoDBClient, + logger, + ); + + const resp = await dao.retrieve(testWidget); + expect(resp).toEqual(testWidget); +}); + +test("when retrieve dynamo dao item not exist then returns undefined", async () => { + const mockClient = { send: mockGetItemSend }; + const logger = new VoidLogger(); + const dao = new DynamoWidgetDAO( + testTable, + mockClient as unknown as DynamoDBClient, + logger, + ); + + const resp = await dao.retrieve({ + id: "this-widget-doesnt-exist", + owner: "simponic", + }); + + expect(resp).toBeUndefined(); +}); + +test("when dynamo dao save then inflates object and returns", async () => { + const mockClient = { + send: expectMockPutItem("new_attribute", "exists"), + }; + const logger = new VoidLogger(); + const dao = new DynamoWidgetDAO( + testTable, + mockClient as unknown as DynamoDBClient, + logger, + ); + + const newWidget = Widget.parse({ + ...testWidget, + otherAttributes: [...testWidget.otherAttributes!], + }); + newWidget.otherAttributes!.push({ name: "new_attribute", value: "exists" }); + + const resp = await dao.save(newWidget); + + expect(resp).toEqual(newWidget); +}); + +test("when dynamo dao delete then returns deleted ", async () => { + const mockClient = { + send: mockDeleteItemSuccessful, + }; + const logger = new VoidLogger(); + + const dao = new DynamoWidgetDAO( + testTable, + mockClient as unknown as DynamoDBClient, + logger, + ); + + const resp = await dao.delete(testWidget); + + expect(resp).toEqual(testWidget); +}); diff --git a/Homework/cs5260/consumer/t/Loop.spec.ts b/Homework/cs5260/consumer/t/Loop.spec.ts new file mode 100644 index 0000000..44ba517 --- /dev/null +++ b/Homework/cs5260/consumer/t/Loop.spec.ts @@ -0,0 +1,48 @@ +import { MainLoop } from "@/consumer/Loop"; +import { WidgetRequestProcessor } from "@/consumer/processor"; +import { WidgetRequestRetriever } from "@/consumer/retrievers"; +import { expect, mock, test } from "bun:test"; +import { VoidLogger } from "@/shared/t"; +import { WidgetRequest } from "@/shared/schema"; +import { randomUUID } from "node:crypto"; + +test("main loop exits after max poll attempts", async () => { + const logger = new VoidLogger(); + + const maxPollAttempts = 3; + const timesToGiveData = 5; + let timesGivenData = 0; + + const mockRetriever: WidgetRequestRetriever = { + refreshRequests: mock(async () => { + if (timesGivenData < timesToGiveData) { + timesGivenData++; + return [ + WidgetRequest.parse({ + type: "create", + owner: "simponic", + widgetId: randomUUID(), + requestId: randomUUID(), + }), + ]; + } + return []; + }), + }; + const mockProcessor: WidgetRequestProcessor = { + processRequest: mock(async (_r) => {}), + }; + + await MainLoop( + [mockRetriever], + [mockProcessor], + maxPollAttempts, + 200, + logger, + ); + + expect(mockRetriever.refreshRequests).toHaveBeenCalledTimes( + timesGivenData + maxPollAttempts, + ); + expect(mockProcessor.processRequest).toHaveBeenCalledTimes(timesGivenData); +}); diff --git a/Homework/cs5260/consumer/t/S3WidgetDao.spec.ts b/Homework/cs5260/consumer/t/S3WidgetDao.spec.ts new file mode 100644 index 0000000..5eb4602 --- /dev/null +++ b/Homework/cs5260/consumer/t/S3WidgetDao.spec.ts @@ -0,0 +1,115 @@ +import { Widget } from "@/shared/schema"; +import { expect, test, mock } from "bun:test"; +import { S3WidgetKeyGenerator } from "@/consumer/dao/keys"; +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { S3WidgetDAO } from "@/consumer/dao"; +import { VoidLogger } from "@/shared/t"; + +const testBucket = "test-bucket"; +const testWidget = Widget.parse({ + id: "testWidgetId", + owner: "simponic", + label: "A Widget", + description: "Nothing to see here!", +}); + +const testKeyGenerator = new S3WidgetKeyGenerator().withPrefix("test-widgets"); + +const mockedGetSend = mock(async (req: GetObjectCommand) => { + if (req.input.Key == "test-widgets/simponic/testWidgetId") { + return { + Body: { + transformToString: () => { + return JSON.stringify(testWidget); + }, + }, + }; + } + return {}; +}); + +const mockedPutSendSuccessful = mock(async (req: PutObjectCommand) => { + if (req.input.Key == "test-widgets/simponic/testWidgetId") { + return { + ChecksumSHA256: "laksdjf-lkejfoaj", + }; + } +}); + +const mockedDeleteSendSuccessful = mock(async (req: DeleteObjectCommand) => { + if (req.input.Key == "test-widgets/simponic/testWidgetId") { + return { + $metadata: true, + }; + } +}); + +test("when given widget then retrieves from s3", async () => { + const mockedClient = { send: mockedGetSend }; + const logger = new VoidLogger(); + + const dao = new S3WidgetDAO( + testBucket, + mockedClient as unknown as S3Client, + testKeyGenerator, + logger, + ); + + const resp = await dao.retrieve({ id: "testWidgetId", owner: "simponic" }); + + expect(resp?.label).toEqual(testWidget.label); + expect(resp?.id).toEqual(testWidget.id); +}); + +test("when cannot retrieve widget then returns undefined", async () => { + const mockedClient = { send: mockedGetSend }; + const logger = new VoidLogger(); + + const dao = new S3WidgetDAO( + testBucket, + mockedClient as unknown as S3Client, + testKeyGenerator, + logger, + ); + + const resp = await dao.retrieve({ + id: "thisWidgetDoesNotExist", + owner: "simponic", + }); + + expect(resp).toBeUndefined(); +}); + +test("when given widget then puts in s3 and returns", async () => { + const mockedClient = { send: mockedPutSendSuccessful }; + const logger = new VoidLogger(); + const dao = new S3WidgetDAO( + testBucket, + mockedClient as unknown as S3Client, + testKeyGenerator, + logger, + ); + + const resp = await dao.save(testWidget); + expect(resp).toEqual(testWidget); +}); + +test("when given widget then deletes in s3 and returns", async () => { + const mockedClient = { send: mockedDeleteSendSuccessful }; + const logger = new VoidLogger(); + + const dao = new S3WidgetDAO( + testBucket, + mockedClient as unknown as S3Client, + testKeyGenerator, + logger, + ); + + const resp = await dao.delete(testWidget); + expect(resp).toEqual(testWidget); +}); diff --git a/Homework/cs5260/consumer/t/S3WidgetKeyGenerator.spec.ts b/Homework/cs5260/consumer/t/S3WidgetKeyGenerator.spec.ts new file mode 100644 index 0000000..642c7a4 --- /dev/null +++ b/Homework/cs5260/consumer/t/S3WidgetKeyGenerator.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { S3WidgetKeyGenerator } from "@/consumer/dao/keys"; +import { Widget } from "@/shared/schema"; + +test("builds keys correctly", () => { + const generator = new S3WidgetKeyGenerator() + .withPrefix("test") + .withWidgetId("test") + .withOwner("simponic"); + + expect(generator.build()).toEqual("test/simponic/test"); +}); + +test("when owner given spaces then replaces with dashes and lowercases", () => { + const generator = new S3WidgetKeyGenerator() + .withPrefix("widgets") + .withWidgetId("test") + .withOwner("simPOnic test"); + + expect(generator.build()).toEqual("widgets/simponic-test/test"); +}); + +test("when not all specifications given then throws", () => { + const generator = new S3WidgetKeyGenerator() + .withPrefix("test") + .withWidgetId("test"); + + expect(() => generator.build()).toThrow(); +}); + +test("when invalid keys given then throws", () => { + const generator = new S3WidgetKeyGenerator() + .withPrefix("test") + .withOwner("simponic/simponic") + .withWidgetId("test"); + + expect(() => generator.build()).toThrow(); +}); + +test("when keys correct then builds from widget", () => { + const generator = new S3WidgetKeyGenerator().withPrefix("test-widgets"); + + const testWidget = Widget.parse({ owner: "simponic", id: "test-id" }); + + expect(generator.fromWidget(testWidget)).toEqual( + "test-widgets/simponic/test-id" + ); +}); diff --git a/Homework/cs5260/consumer/t/S3WidgetRequestRetriever.spec.ts b/Homework/cs5260/consumer/t/S3WidgetRequestRetriever.spec.ts new file mode 100644 index 0000000..9807875 --- /dev/null +++ b/Homework/cs5260/consumer/t/S3WidgetRequestRetriever.spec.ts @@ -0,0 +1,172 @@ +import { S3WidgetRequestRetriever } from "@/consumer/retrievers"; +import { WidgetRequest } from "@/shared/schema"; +import { + DeleteObjectCommand, + GetObjectCommand, + ListObjectsV2Command, + S3Client, +} from "@aws-sdk/client-s3"; +import { expect, mock, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { VoidLogger } from "@/shared/t"; + +test("when widget request in S3 then reads, deletes, and returns", async () => { + const logger = new VoidLogger(); + const widgetRequest = WidgetRequest.parse({ + type: "create", + requestId: randomUUID(), + owner: "simponic", + widgetId: randomUUID(), + }); + + const key = "test-key"; + + const deleteKeyMock = mock(() => true); + const listKeyMock = mock(() => { + return { + Contents: [{ Key: key }], + }; + }); + const getKeyMock = mock(() => { + return { + Body: { + transformToString: async () => JSON.stringify(widgetRequest), + }, + }; + }); + + const mockS3Client = { + send: async ( + req: ListObjectsV2Command | DeleteObjectCommand | GetObjectCommand, + ) => { + if (req instanceof ListObjectsV2Command) { + return listKeyMock(); + } + + if (req instanceof DeleteObjectCommand) { + return deleteKeyMock(); + } + + if (req instanceof GetObjectCommand) { + if (req.input.Key === key) { + return getKeyMock(); + } + } + }, + }; + + const retriever = new S3WidgetRequestRetriever( + "test-bucket", + mockS3Client as unknown as S3Client, + logger, + ); + + expect(await retriever.refreshRequests()).toEqual([widgetRequest]); + expect(getKeyMock).toHaveBeenCalledTimes(1); + expect(listKeyMock).toHaveBeenCalledTimes(1); + expect(deleteKeyMock).toHaveBeenCalledTimes(1); +}); + +test("when empty widget request in S3 then deletes and returns empty", async () => { + const logger = new VoidLogger(); + const key = "test-key"; + + const deleteKeyMock = mock(() => true); + const listKeyMock = mock(() => { + return { + Contents: [{ Key: key }], + }; + }); + const getKeyMock = mock(() => { + return { + Body: { + transformToString: async () => "", + }, + }; + }); + + const mockS3Client = { + send: mock( + async ( + req: ListObjectsV2Command | DeleteObjectCommand | GetObjectCommand, + ) => { + if (req instanceof ListObjectsV2Command) { + return listKeyMock(); + } + + if (req instanceof DeleteObjectCommand) { + return deleteKeyMock(); + } + + if (req instanceof GetObjectCommand) { + if (req.input.Key === key) { + return getKeyMock(); + } + } + }, + ), + }; + + const retriever = new S3WidgetRequestRetriever( + "test-bucket", + mockS3Client as unknown as S3Client, + logger, + ); + + expect(await retriever.refreshRequests()).toBeEmpty(); + expect(getKeyMock).toHaveBeenCalledTimes(1); + expect(listKeyMock).toHaveBeenCalledTimes(1); + expect(deleteKeyMock).toHaveBeenCalledTimes(1); +}); + +test("when no keys then returns empty", async () => { + const logger = new VoidLogger(); + const key = "test-key"; + + const deleteKeyMock = mock(() => true); + const listKeyMock = mock(() => { + return { + Contents: [], + }; + }); + const getKeyMock = mock(() => { + return { + Body: { + transformToString: async () => "", + }, + }; + }); + + const mockS3Client = { + send: mock( + async ( + req: ListObjectsV2Command | DeleteObjectCommand | GetObjectCommand, + ) => { + if (req instanceof ListObjectsV2Command) { + return listKeyMock(); + } + + if (req instanceof DeleteObjectCommand) { + return deleteKeyMock(); + } + + if (req instanceof GetObjectCommand) { + if (req.input.Key === key) { + return getKeyMock(); + } + } + }, + ), + }; + + const retriever = new S3WidgetRequestRetriever( + "test-bucket", + mockS3Client as unknown as S3Client, + logger, + ); + + expect(await retriever.refreshRequests()).toEqual([]); + expect(listKeyMock).toHaveBeenCalledTimes(1); + expect(getKeyMock).toHaveBeenCalledTimes(0); + expect(deleteKeyMock).toHaveBeenCalledTimes(0); +}); diff --git a/Homework/cs5260/consumer/t/SQSWidgetRequestRetriever.spec.ts b/Homework/cs5260/consumer/t/SQSWidgetRequestRetriever.spec.ts new file mode 100644 index 0000000..cae6a65 --- /dev/null +++ b/Homework/cs5260/consumer/t/SQSWidgetRequestRetriever.spec.ts @@ -0,0 +1,95 @@ +import { SQSWidgetRequestRetriever } from "@/consumer/retrievers"; +import { WidgetRequest } from "@/shared/schema"; +import { + DeleteMessageBatchCommand, + ReceiveMessageCommand, + SQSClient, +} from "@aws-sdk/client-sqs"; +import { expect, mock, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { VoidLogger } from "@/shared/t"; + +test("when widget request in SQS then reads, deletes, and returns", async () => { + const logger = new VoidLogger(); + const widgetRequest = WidgetRequest.parse({ + type: "create", + requestId: randomUUID(), + owner: "simponic", + widgetId: randomUUID(), + }); + + const queueUrl = "https://asdf.fdsa.asdf"; + + const deleteMessagesBatch = mock(() => true); + const getMessages = mock(() => { + return { + Messages: [ + { + Body: JSON.stringify(widgetRequest), + MessageId: "fdsa", + ReceiptHandle: "asdf", + }, + ], + }; + }); + + const mockSQSClient = { + send: async (req: ReceiveMessageCommand | DeleteMessageBatchCommand) => { + if (req instanceof ReceiveMessageCommand) { + if (req.input.QueueUrl === queueUrl) return getMessages(); + } + if (req instanceof DeleteMessageBatchCommand) { + expect( + req.input.Entries?.find( + (x) => x.Id === "fdsa" && x.ReceiptHandle === "asdf", + ), + ).toBeTruthy(); + + if (req.input.QueueUrl === queueUrl) return deleteMessagesBatch(); + } + return; + }, + }; + + const retriever = new SQSWidgetRequestRetriever( + queueUrl, + mockSQSClient as unknown as SQSClient, + logger, + ); + + expect(await retriever.refreshRequests()).toEqual([widgetRequest]); + expect(deleteMessagesBatch).toHaveBeenCalledTimes(1); + expect(getMessages).toHaveBeenCalledTimes(1); +}); + +test("when empty widget request in SQS then returns empty", async () => { + const logger = new VoidLogger(); + const queueUrl = "https://asdf.fdsa.asdf"; + + const getMessages = mock(() => { + return {}; + }); + const deleteMessagesBatch = mock(() => true); + + const mockSQSClient = { + send: async (req: ReceiveMessageCommand | DeleteMessageBatchCommand) => { + if (req instanceof ReceiveMessageCommand) { + if (req.input.QueueUrl === queueUrl) return getMessages(); + } + if (req instanceof DeleteMessageBatchCommand) { + if (req.input.QueueUrl === queueUrl) return deleteMessagesBatch(); + } + return; + }, + }; + + const retriever = new SQSWidgetRequestRetriever( + queueUrl, + mockSQSClient as unknown as SQSClient, + logger, + ); + + expect(await retriever.refreshRequests()).toBeEmpty(); + expect(getMessages).toHaveBeenCalledTimes(1); + expect(deleteMessagesBatch).toHaveBeenCalledTimes(0); +}); diff --git a/Homework/cs5260/consumer/t/index.ts b/Homework/cs5260/consumer/t/index.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/Homework/cs5260/consumer/t/index.ts |
