diff --git a/src/auth/profile/dto/update-job-preferences.input.spec.ts b/src/auth/profile/dto/update-job-preferences.input.spec.ts new file mode 100644 index 00000000..382c30b3 --- /dev/null +++ b/src/auth/profile/dto/update-job-preferences.input.spec.ts @@ -0,0 +1,48 @@ +import { ValidationPipe } from "@nestjs/common"; +import { UpdateJobPreferencesInput } from "./update-job-preferences.input"; + +describe("Job preference UTC offset validation", () => { + const pipe = new ValidationPipe({ enableDebugMessages: false }); + const validate = (utcOffset: unknown): Promise => + pipe.transform( + { workModes: ["remote"], utcOffset }, + { type: "body", metatype: UpdateJobPreferencesInput }, + ); + + it.each(Array.from({ length: 105 }, (_, index) => -12 + index / 4))( + "accepts UTC offset %s", + async utcOffset => { + await expect(validate(utcOffset)).resolves.toMatchObject({ utcOffset }); + }, + ); + + it.each(["3", "5.5", "5.75", "-3.5"])( + "accepts existing numeric string inputs for %s", + async utcOffset => { + await expect(validate(utcOffset)).resolves.toMatchObject({ + utcOffset: Number(utcOffset), + }); + }, + ); + + it.each([null, undefined])("allows an unset offset (%s)", async utcOffset => { + await expect(validate(utcOffset)).resolves.toMatchObject({ utcOffset }); + }); + + it.each([-12.25, 14.25, 3.1, 5.6, -3.3, 3.001, "invalid", NaN, Infinity])( + "rejects invalid UTC offset %s", + async utcOffset => { + await expect(validate(utcOffset)).rejects.toThrow(); + }, + ); + + it("explains the allowed increments for an invalid offset", async () => { + await expect(validate(3.1)).rejects.toMatchObject({ + response: { + message: [ + "UTC offset must use quarter-hour increments, such as 3, 5.5, or 5.75", + ], + }, + }); + }); +}); diff --git a/src/auth/profile/dto/update-job-preferences.input.ts b/src/auth/profile/dto/update-job-preferences.input.ts index 3bb06543..49ec1b5c 100644 --- a/src/auth/profile/dto/update-job-preferences.input.ts +++ b/src/auth/profile/dto/update-job-preferences.input.ts @@ -7,7 +7,6 @@ import { ArrayUnique, IsArray, IsBoolean, - IsDivisibleBy, IsIn, IsNumber, IsOptional, @@ -26,6 +25,7 @@ import { WORK_MODES, WorkMode, } from "src/shared/interfaces"; +import { IsQuarterHourOffset } from "src/shared/validators/quarter-hour-offset.validator"; const IsPreferenceList = (): ReturnType => applyDecorators( @@ -58,7 +58,7 @@ export class UpdateJobPreferencesInput { @IsOptional() @Type(() => Number) @IsNumber({ maxDecimalPlaces: 2 }) - @IsDivisibleBy(0.25) + @IsQuarterHourOffset() @Min(-12) @Max(14) utcOffset: number | null; diff --git a/src/shared/validators/quarter-hour-offset.validator.ts b/src/shared/validators/quarter-hour-offset.validator.ts new file mode 100644 index 00000000..0c69ddbf --- /dev/null +++ b/src/shared/validators/quarter-hour-offset.validator.ts @@ -0,0 +1,20 @@ +import { ValidateBy, ValidationOptions } from "class-validator"; + +export function IsQuarterHourOffset( + validationOptions?: ValidationOptions, +): PropertyDecorator { + return ValidateBy( + { + name: "isQuarterHourOffset", + validator: { + // IsDivisibleBy truncates fractional divisors to integers. Scaling + // hours to quarter-hours avoids that and is exact for multiples of .25. + validate: (value: unknown): boolean => + typeof value === "number" && Number.isInteger(value * 4), + defaultMessage: () => + "UTC offset must use quarter-hour increments, such as 3, 5.5, or 5.75", + }, + }, + validationOptions, + ); +}