Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions handlers/product-switch-api/test/amendments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,24 @@ import { getLastAmendment } from '../src/amendments';
test('should return undefined when subscription has no amendment (code 50000040)', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
success: false,
processId: 'FF96F5A03C9DC715',
reasons: [
{
code: 50000040,
message: 'This is the original subscription and has no amendment.',
},
],
requestId: 'e4e11170-6b27-450c-adf1-f4681de16e21',
}),
headers: {
entries: () => [['Content-Type', 'application/json; charset=utf-8']],
},
Comment on lines +8 to +10
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

headers are required for fetch - https://developer.mozilla.org/en-US/docs/Web/API/Response/headers
and TS types reflect that
image
so I think it's safe for us to require it in the restclient.

text: () =>
Promise.resolve(
JSON.stringify({
success: false,
processId: 'FF96F5A03C9DC715',
reasons: [
{
code: 50000040,
message:
'This is the original subscription and has no amendment.',
},
],
requestId: 'e4e11170-6b27-450c-adf1-f4681de16e21',
}),
),
});

global.fetch = mockFetch;
Expand Down
34 changes: 22 additions & 12 deletions modules/routing/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,24 +80,34 @@ export class Logger {
return String(value);
}
if (value instanceof Error) {
return value.stack ?? '';
return (
(value.stack ?? '') +
'\n' +
this.objectToPrettyString(value) +
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any extra properties of the error don't get printed by node's e.stack by default

(value.cause ? '\nCaused by: ' + this.prettyPrint(value.cause) : '')
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cause doesn't get printed by node's e.stack by default

);
}
if (typeof value === 'object' || Array.isArray(value)) {
try {
const jsonString = JSON.stringify(value)
.replace(/"([^"]+)":/g, ' $1: ') // Remove quotes around keys
.replace(/}$/, ' }');
if (jsonString.length <= 80) {
return jsonString;
}
return JSON.stringify(value, null, 2).replace(/"([^"]+)":/g, '$1:');
} catch {
return String(value);
}
return this.objectToPrettyString(value);
}
return String(value);
};

private objectToPrettyString(object: unknown) {
try {
const jsonString = JSON.stringify(object)
.replace(/"([^"]+)":/g, ' $1: ') // Remove quotes around keys
.replace(/}$/, ' }');
if (jsonString.length <= 80) {
return jsonString;
}
return JSON.stringify(object, null, 2).replace(/"([^"]+)":/g, '$1:');
} catch (e) {
console.error('caught error when trying to serialise log line', e);
return String(object);
}
}

/* eslint-enable @typescript-eslint/no-explicit-any */
/* eslint-enable @typescript-eslint/no-unsafe-argument */

Expand Down
41 changes: 37 additions & 4 deletions modules/routing/src/router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mapPartition, zipAll } from '@modules/arrayFunctions';
import { ValidationError } from '@modules/errors';
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import type { ZodTypeDef } from 'zod';
import { z } from 'zod';
import { logger } from '@modules/routing/logger';

Expand All @@ -21,8 +22,8 @@ export type Route<TPath, TBody> = {
parsed: { path: TPath; body: TBody },
) => Promise<APIGatewayProxyResult>;
parser?: {
path?: z.Schema<TPath>;
body?: z.Schema<TBody>;
path?: z.Schema<TPath, ZodTypeDef, unknown>;
body?: z.Schema<TBody, ZodTypeDef, unknown>;
Comment on lines +25 to +26
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this means that we can use .transform on the schema e.g. if we want to turn string into UUID or something

};
};

Expand All @@ -38,18 +39,50 @@ export const NotFoundResponse = {
statusCode: 404,
};

/**
* if routeParts ends with a greedy `+`, batch together the last eventsParts accordingly
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for some reason, when I try to write algorithmic code in TS it always ends up looking horrible.

*/
export function zipRouteWithEventPath(
routeParts: string[],
eventParts: string[],
) {
const lastRoutePart: string | undefined = routeParts[routeParts.length - 1];
const routeIsGreedy = lastRoutePart?.endsWith('+}');
let adjustedEventParts;
let adjustedRouteParts;
if (lastRoutePart && routeIsGreedy && routeParts.length < eventParts.length) {
const excessParts = eventParts.slice(routeParts.length - 1);
const joinedGreedyValue = excessParts.join('/');
adjustedEventParts = [
...eventParts.slice(0, routeParts.length - 1),
joinedGreedyValue,
];
const adjustedLastRoutePart = lastRoutePart.replace(/\+}/, '}');
adjustedRouteParts = [
...routeParts.slice(0, routeParts.length - 1),
adjustedLastRoutePart,
];
} else if (routeParts.length === eventParts.length) {
adjustedEventParts = eventParts;
adjustedRouteParts = routeParts;
} else {
return undefined;
}
return zipAll(adjustedRouteParts, adjustedEventParts, '', '');
}

function matchPath(
routePath: string,
eventPath: string,
): { params: Record<string, string> } | undefined {
const routeParts = routePath.split('/').filter(Boolean);
const eventParts = eventPath.split('/').filter(Boolean);

if (routeParts.length !== eventParts.length) {
const routeEventPairs = zipRouteWithEventPath(routeParts, eventParts);
if (routeEventPairs === undefined) {
return undefined;
}

const routeEventPairs = zipAll(routeParts, eventParts, '', '');
const [matchers, literals] = mapPartition(
routeEventPairs,
([routePart, eventPart]) => {
Expand Down
37 changes: 34 additions & 3 deletions modules/routing/test/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,32 @@ describe('Logger.joinLines', () => {
expect(actual).toEqual(`msg`);
});

test('should pretty print errors correctly', () => {
const logger = new Logger();
const error = new Error('Test error', {
cause: new MyError('another error'),
});

const actual = logger.prettyPrint(error);

const actualWithoutStackLines = actual
.split('\n')
.filter((s) => !s.startsWith(' at'));
expect(actualWithoutStackLines).toEqual([
'Error: Test error',
'{ }',
'Caused by: MyError: my error',
'{ custom: "another error", name: "MyError" }',
]);
});

class MyError extends Error {
constructor(public custom: string) {
super('my error');
this.name = 'MyError';
}
}

test('should join primitive types, arrays, objects, and errors with compact or pretty JSON formatting without quotes around keys', () => {
const logger = new Logger();
const primitives = [42, 'hello', true, null, undefined];
Expand Down Expand Up @@ -204,7 +230,9 @@ describe('Logger.joinLines', () => {
s: 'i',
t: 'h',
};
const error = new Error('Test error');
const error = new Error('Test error', {
cause: new MyError('another error'),
});

const result = [
...primitives,
Expand Down Expand Up @@ -274,8 +302,11 @@ describe('Logger.joinLines', () => {
r: "j",
s: "i",
t: "h"
}\n` +
error.stack,
}
${error.stack}
{ }
Caused by: ${(error.cause as Error).stack}
{ custom: "another error", name: "MyError" }`,
);
});
});
32 changes: 32 additions & 0 deletions modules/routing/test/zipRouteWithEventPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { zipRouteWithEventPath } from '../src/router';

describe('zipRouteWithEventPath', () => {
test('matches route and event parts of equal length', () => {
const routeParts = ['benefits', '{benefitId}', 'users'];
const eventParts = ['benefits', '123', 'users'];
expect(zipRouteWithEventPath(routeParts, eventParts)).toEqual([
['benefits', 'benefits'],
['{benefitId}', '123'],
['users', 'users'],
]);
});

test('handles greedy route param at the end', () => {
const routeParts = ['files', '{path+}'];
const eventParts = ['files', 'a', 'b', 'c.txt'];
expect(zipRouteWithEventPath(routeParts, eventParts)).toEqual([
['files', 'files'],
['{path}', 'a/b/c.txt'],
]);
});

test('returns undefined for mismatched lengths (non-greedy)', () => {
const routeParts = ['benefits', '{benefitId}', 'users'];
const eventParts = ['benefits', '123'];
expect(zipRouteWithEventPath(routeParts, eventParts)).toBeUndefined();
});

test('matches when both are empty', () => {
expect(zipRouteWithEventPath([], [])).toEqual([]);
});
});
109 changes: 109 additions & 0 deletions modules/try.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Try, Success, Failure } from './try';

describe('Success', () => {
it('should return the value with get', () => {
const result = Success(42);
expect(result.get()).toBe(42);
});

it('should return the value with getOrElse', () => {
const result = Success(42);
expect(result.getOrElse(100)).toBe(42);
});

it('should have success true', () => {
const result = Success(42);
expect(result.success).toBe(true);
});

it('should flatMap to a new Success', () => {
const result = Success(42);
const mapped = result.flatMap((n) => Success(n * 2));
expect(mapped.success).toBe(true);
expect(mapped.get()).toBe(84);
});

it('should flatMap to a Failure', () => {
const result = Success(42);
const error = new Error('flatMap error');
const mapped = result.flatMap(() => Failure(error));
expect(mapped.success).toBe(false);
expect((mapped as Failure<void>).failure).toBe(error);
});

it('should not apply mapError', () => {
const result = Success(42);
const mapped = result.mapError(() => new Error('should not be called'));
expect(mapped.success).toBe(true);
expect(mapped.get()).toBe(42);
});
});

describe('Failure', () => {
const error = new Error('test error');

it('should throw error on get', () => {
const result = Failure<number>(error);
expect(() => result.get()).toThrow('test error');
});

it('should return default value with getOrElse', () => {
const result = Failure<number>(error);
expect(result.getOrElse(100)).toBe(100);
});

it('should have success false', () => {
const result = Failure<number>(error);
expect(result.success).toBe(false);
});

it('should expose the failure error', () => {
const result = Failure<number>(error);
expect(result.failure).toBe(error);
});

it('should not execute flatMap', () => {
const result = Failure<number>(error);
const mapped = result.flatMap((n) => Success(n * 2));
expect(mapped.success).toBe(false);
expect((mapped as Failure<number>).failure).toBe(error);
});

it('should apply mapError', () => {
const result = Failure<number>(error);
const wrapperMessage = 'mapped error';
const mapped = result.mapError(
(e) => new Error(wrapperMessage, { cause: e }),
);
expect(mapped.success).toBe(false);
const finalFailure = (mapped as Failure<number>).failure;
expect(finalFailure.message).toBe(wrapperMessage);
expect(finalFailure.cause).toBe(error);
});
});

describe('Try', () => {
it('should return Success for successful operation', () => {
const result = Try(() => 42);
expect(result.success).toBe(true);
expect(result.get()).toBe(42);
});

it('should return Failure for throwing operation', () => {
const error = new Error('operation failed');
const result = Try(() => {
throw error;
});
expect(result.success).toBe(false);
expect((result as Failure<never>).failure).toBe(error);
});

it('should chain multiple operations with flatMap', () => {
const result = Try(() => 10)
.flatMap((n) => Success(n * 2))
.flatMap((n) => Success(n + 5));

expect(result.success).toBe(true);
expect(result.get()).toBe(25);
});
});
Loading
Loading