diff --git a/packages/schemas/__tests__/ape-keys.spec.ts b/packages/schemas/__tests__/ape-keys.spec.ts new file mode 100644 index 000000000000..69fd2aa64916 --- /dev/null +++ b/packages/schemas/__tests__/ape-keys.spec.ts @@ -0,0 +1,156 @@ +import { it, expect, describe } from "vitest"; +import { + ApeKeyNameSchema, + ApeKeyUserDefinedSchema, + ApeKeySchema, + ApeKeysSchema, +} from "../src/ape-keys"; + +describe("ape-keys schemas", () => { + describe("ApeKeyNameSchema", () => { + it.each([ + { + description: "valid slug within max length", + input: "my-ape-key", + }, + { + description: "exceeds max length", + input: "this-name-is-way-too-long-for-schema", + expectedError: "String must contain at most 20 character", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ApeKeyNameSchema).toReject(input, expectedError); + } else { + expect(ApeKeyNameSchema).toValidate(input); + } + }); + }); + + describe("ApeKeyUserDefinedSchema", () => { + it.each([ + { + description: "minimal valid user-defined ape key", + input: { + name: "test-key", + enabled: true, + }, + }, + { + description: "missing name", + input: { enabled: false }, + expectedError: "Required", + }, + { + description: "missing enabled", + input: { name: "test-key" }, + expectedError: "Required", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ApeKeyUserDefinedSchema).toReject(input, expectedError); + } else { + expect(ApeKeyUserDefinedSchema).toValidate(input); + } + }); + }); + + describe("ApeKeySchema", () => { + it.each([ + { + description: "minimal valid ape key", + input: { + name: "test-key", + enabled: true, + createdOn: 0, + modifiedOn: 0, + lastUsedOn: 0, + }, + }, + { + description: "lastUsedOn is -1", + input: { + name: "test-key", + enabled: false, + createdOn: 1234567890, + modifiedOn: 1234567890, + lastUsedOn: -1, + }, + }, + { + description: "missing createdOn", + input: { + name: "test-key", + enabled: true, + modifiedOn: 0, + lastUsedOn: 0, + }, + expectedError: "Required", + }, + { + description: "createdOn negative", + input: { + name: "test-key", + enabled: true, + createdOn: -1, + modifiedOn: 0, + lastUsedOn: 0, + }, + expectedError: "Number must be greater than or equal to 0", + }, + { + description: "lastUsedOn negative and not -1", + input: { + name: "test-key", + enabled: true, + createdOn: 0, + modifiedOn: 0, + lastUsedOn: -2, + }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ApeKeySchema).toReject(input, expectedError); + } else { + expect(ApeKeySchema).toValidate(input); + } + }); + }); + + describe("ApeKeysSchema", () => { + it.each([ + { + description: "valid record of ape keys", + input: { + key1: { + name: "test-key", + enabled: true, + createdOn: 0, + modifiedOn: 0, + lastUsedOn: 0, + }, + }, + }, + { + description: "invalid value in record", + input: { + key1: { + name: "test-key", + enabled: true, + createdOn: -1, + modifiedOn: 0, + lastUsedOn: 0, + }, + }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ApeKeysSchema).toReject(input, expectedError); + } else { + expect(ApeKeysSchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/challenges.spec.ts b/packages/schemas/__tests__/challenges.spec.ts new file mode 100644 index 000000000000..9230bb0e5703 --- /dev/null +++ b/packages/schemas/__tests__/challenges.spec.ts @@ -0,0 +1,91 @@ +import { it, expect, describe } from "vitest"; +import { ChallengeSchema } from "../src/challenges"; + +describe("challenges schema", () => { + describe("ChallengeSchema", () => { + it.each([ + { + description: "minimal valid challenge", + input: { + name: "test-challenge", + display: "Test Challenge", + type: "other", + parameters: [], + }, + }, + { + description: "full challenge with requirements", + input: { + name: "speed-run", + display: "Speed Run", + autoRole: true, + type: "customTime", + message: "Complete quickly", + parameters: [60, true, null, "easy", ["58008"]], + requirements: { + wpm: { min: 100 }, + acc: { exact: 0.95 }, + afk: { max: 5 }, + time: { min: 60 }, + funbox: { exact: ["58008"] }, + raw: { exact: 120 }, + con: { exact: 10 }, + config: { punctuation: true }, + }, + }, + }, + { + description: "exact wpm challenge", + input: { + name: "exact-wpm", + display: "Exact WPM", + type: "accuracy", + parameters: [], + requirements: { wpm: { exact: 50 } }, + }, + }, + { + description: "missing name", + input: { display: "Test", type: "other", parameters: [] }, + expectedError: "Required", + }, + { + description: "missing display", + input: { name: "test", type: "other", parameters: [] }, + expectedError: "Required", + }, + { + description: "missing type", + input: { name: "test", display: "Test", parameters: [] }, + expectedError: "Required", + }, + { + description: "invalid type enum", + input: { + name: "test", + display: "Test", + type: "invalid", + parameters: [], + }, + expectedError: "Invalid enum value", + }, + { + description: "unrecognized key", + input: { + name: "test", + display: "Test", + type: "other", + parameters: [], + extra: true, + }, + expectedError: "Unrecognized key", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ChallengeSchema).toReject(input, expectedError); + } else { + expect(ChallengeSchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/config.spec.ts b/packages/schemas/__tests__/config.spec.ts deleted file mode 100644 index 50db32000f94..000000000000 --- a/packages/schemas/__tests__/config.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { CustomBackgroundSchema } from "@monkeytype/schemas/configs"; - -describe("config schema", () => { - describe("CustomBackgroundSchema", () => { - it.for([ - { - name: "http", - input: `http://example.com/path/image.png`, - }, - { - name: "https", - input: `https://example.com/path/image.png`, - }, - { - name: "png", - input: `https://example.com/path/image.png`, - }, - { - name: "gif", - input: `https://example.com/path/image.gif?width=5`, - }, - { - name: "jpeg", - input: `https://example.com/path/image.jpeg`, - }, - { - name: "jpg", - input: `https://example.com/path/image.jpg`, - }, - { - name: "tiff", - input: `https://example.com/path/image.tiff`, - expectedError: "Unsupported image format", - }, - { - name: "non-url", - input: `test`, - expectedError: "Needs to be an URI", - }, - { - name: "single quotes", - input: `https://example.com/404.jpg?q=alert('1')`, - expectedError: "May not contain quotes", - }, - { - name: "double quotes", - input: `https://example.com/404.jpg?q=alert("1")`, - expectedError: "May not contain quotes", - }, - { - name: "back tick", - input: `https://example.com/404.jpg?q=alert(\`1\`)`, - expectedError: "May not contain quotes", - }, - { - name: "javascript url", - input: `javascript:alert('asdf');//https://example.com/img.jpg`, - expectedError: "Unsupported protocol", - }, - { - name: "data url", - input: `data:image/gif;base64,data`, - expectedError: "Unsupported protocol", - }, - { - name: "long url", - input: `https://example.com/path/image.jpeg?q=${new Array(2048) - .fill("x") - .join()}`, - expectedError: "URL is too long", - }, - ])(`$name`, ({ input, expectedError }) => { - const parsed = CustomBackgroundSchema.safeParse(input); - if (expectedError !== undefined) { - expect(parsed.success).toEqual(false); - expect(parsed.error?.issues[0]?.message).toEqual(expectedError); - } else { - expect(parsed.success).toEqual(true); - } - }); - }); -}); diff --git a/packages/schemas/__tests__/configs.spec.ts b/packages/schemas/__tests__/configs.spec.ts new file mode 100644 index 000000000000..fcefaec3d3a6 --- /dev/null +++ b/packages/schemas/__tests__/configs.spec.ts @@ -0,0 +1,1560 @@ +import { it, expect, describe } from "vitest"; +import { + SmoothCaretSchema, + QuickRestartSchema, + QuoteLengthSchema, + CaretStyleSchema, + ConfidenceModeSchema, + IndicateTyposSchema, + CompositionDisplaySchema, + TimerStyleSchema, + LiveSpeedAccBurstStyleSchema, + RandomThemeSchema, + TimerColorSchema, + TimerOpacitySchema, + StopOnErrorSchema, + KeymapModeSchema, + KeymapStyleSchema, + KeymapLegendStyleSchema, + KeymapShowTopRowSchema, + SingleListCommandLineSchema, + PlaySoundOnErrorSchema, + PlaySoundOnClickSchema, + PaceCaretSchema, + MinimumWordsPerMinuteSchema, + HighlightModeSchema, + TypedEffectSchema, + TapeModeSchema, + TypingSpeedUnitSchema, + AdsSchema, + MinimumAccuracySchema, + RepeatQuotesSchema, + OppositeShiftModeSchema, + CustomBackgroundSchema, + CustomBackgroundSizeSchema, + MonkeyPowerLevelSchema, + MinimumBurstSchema, + ShowAverageSchema, + FunboxNameSchema, + PlayTimeWarningSchema, + ConfigGroupNameSchema, + QuoteLengthConfigSchema, + KeymapSizeSchema, + SoundVolumeSchema, + AccountChartSchema, + TapeMarginSchema, + CustomBackgroundFilterSchema, + CustomLayoutFluidSchema, + CustomPolyglotSchema, + ShowPbSchema, + ColorHexValueSchema, + CustomThemeColorsSchema, + FunboxSchema, + PaceCaretCustomSpeedSchema, + MinWpmCustomSpeedSchema, + MinimumAccuracyCustomSchema, + MinimumBurstCustomSpeedSchema, + TimeConfigSchema, + WordCountSchema, + KeymapLayoutSchema, + LayoutSchema, + FontSizeSchema, + MaxLineWidthSchema, + ConfigSchema, + ConfigKeySchema, + PartialConfigSchema, + FavThemesSchema, +} from "../src/configs"; + +describe("configs schemas", () => { + describe("SmoothCaretSchema", () => { + it.each([ + { + description: "valid value 'off'", + input: "off", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'slow' | 'medium' | 'fast', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(SmoothCaretSchema).toReject(input, expectedError); + } else { + expect(SmoothCaretSchema).toValidate(input); + } + }); + }); + + describe("QuickRestartSchema", () => { + it.each([ + { + description: "valid value 'esc'", + input: "esc", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'esc' | 'tab' | 'enter', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(QuickRestartSchema).toReject(input, expectedError); + } else { + expect(QuickRestartSchema).toValidate(input); + } + }); + }); + + describe("QuoteLengthSchema", () => { + it.each([ + { + description: "valid value -3", + input: -3, + }, + { + description: "invalid value", + input: 4, + expectedError: "Invalid input", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(QuoteLengthSchema).toReject(input, expectedError); + } else { + expect(QuoteLengthSchema).toValidate(input); + } + }); + }); + + describe("CaretStyleSchema", () => { + it.each([ + { + description: "valid value 'monkey'", + input: "monkey", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'default' | 'block' | 'outline' | 'underline' | 'carrot' | 'banana' | 'monkey', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CaretStyleSchema).toReject(input, expectedError); + } else { + expect(CaretStyleSchema).toValidate(input); + } + }); + }); + + describe("ConfidenceModeSchema", () => { + it.each([ + { + description: "valid value 'max'", + input: "max", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'on' | 'max', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ConfidenceModeSchema).toReject(input, expectedError); + } else { + expect(ConfidenceModeSchema).toValidate(input); + } + }); + }); + + describe("IndicateTyposSchema", () => { + it.each([ + { + description: "valid value 'both'", + input: "both", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'below' | 'replace' | 'both', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(IndicateTyposSchema).toReject(input, expectedError); + } else { + expect(IndicateTyposSchema).toValidate(input); + } + }); + }); + + describe("CompositionDisplaySchema", () => { + it.each([ + { + description: "valid value 'replace'", + input: "replace", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'below' | 'replace', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CompositionDisplaySchema).toReject(input, expectedError); + } else { + expect(CompositionDisplaySchema).toValidate(input); + } + }); + }); + + describe("TimerStyleSchema", () => { + it.each([ + { + description: "valid value 'flash_mini'", + input: "flash_mini", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'bar' | 'text' | 'mini' | 'flash_text' | 'flash_mini', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(TimerStyleSchema).toReject(input, expectedError); + } else { + expect(TimerStyleSchema).toValidate(input); + } + }); + }); + + describe("LiveSpeedAccBurstStyleSchema", () => { + it.each([ + { + description: "valid value 'mini'", + input: "mini", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'text' | 'mini', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(LiveSpeedAccBurstStyleSchema).toReject(input, expectedError); + } else { + expect(LiveSpeedAccBurstStyleSchema).toValidate(input); + } + }); + }); + + describe("RandomThemeSchema", () => { + it.each([ + { + description: "valid value 'auto'", + input: "auto", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'on' | 'fav' | 'light' | 'dark' | 'custom' | 'auto', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(RandomThemeSchema).toReject(input, expectedError); + } else { + expect(RandomThemeSchema).toValidate(input); + } + }); + }); + + describe("TimerColorSchema", () => { + it.each([ + { + description: "valid value 'main'", + input: "main", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'black' | 'sub' | 'text' | 'main', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(TimerColorSchema).toReject(input, expectedError); + } else { + expect(TimerColorSchema).toValidate(input); + } + }); + }); + + describe("TimerOpacitySchema", () => { + it.each([ + { + description: "valid value '0.75'", + input: "0.75", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected '0.25' | '0.5' | '0.75' | '1', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(TimerOpacitySchema).toReject(input, expectedError); + } else { + expect(TimerOpacitySchema).toValidate(input); + } + }); + }); + + describe("StopOnErrorSchema", () => { + it.each([ + { + description: "valid value 'letter'", + input: "letter", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'word' | 'letter', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(StopOnErrorSchema).toReject(input, expectedError); + } else { + expect(StopOnErrorSchema).toValidate(input); + } + }); + }); + + describe("KeymapModeSchema", () => { + it.each([ + { + description: "valid value 'react'", + input: "react", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'static' | 'react' | 'next', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(KeymapModeSchema).toReject(input, expectedError); + } else { + expect(KeymapModeSchema).toValidate(input); + } + }); + }); + + describe("KeymapStyleSchema", () => { + it.each([ + { + description: "valid value 'steno_matrix'", + input: "steno_matrix", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'staggered' | 'alice' | 'matrix' | 'split' | 'split_matrix' | 'steno' | 'steno_matrix', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(KeymapStyleSchema).toReject(input, expectedError); + } else { + expect(KeymapStyleSchema).toValidate(input); + } + }); + }); + + describe("KeymapLegendStyleSchema", () => { + it.each([ + { + description: "valid value 'dynamic'", + input: "dynamic", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'lowercase' | 'uppercase' | 'blank' | 'dynamic', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(KeymapLegendStyleSchema).toReject(input, expectedError); + } else { + expect(KeymapLegendStyleSchema).toValidate(input); + } + }); + }); + + describe("KeymapShowTopRowSchema", () => { + it.each([ + { + description: "valid value 'layout'", + input: "layout", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'always' | 'layout' | 'never', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(KeymapShowTopRowSchema).toReject(input, expectedError); + } else { + expect(KeymapShowTopRowSchema).toValidate(input); + } + }); + }); + + describe("SingleListCommandLineSchema", () => { + it.each([ + { + description: "valid value 'on'", + input: "on", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'manual' | 'on', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(SingleListCommandLineSchema).toReject(input, expectedError); + } else { + expect(SingleListCommandLineSchema).toValidate(input); + } + }); + }); + + describe("PlaySoundOnErrorSchema", () => { + it.each([ + { + description: "valid value '3'", + input: "3", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | '1' | '2' | '3' | '4', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(PlaySoundOnErrorSchema).toReject(input, expectedError); + } else { + expect(PlaySoundOnErrorSchema).toValidate(input); + } + }); + }); + + describe("PlaySoundOnClickSchema", () => { + it.each([ + { + description: "valid value '13'", + input: "13", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12' | '13' | '14' | '15' | '16' | '17' | '18' | '19' | '20' | '21' | '22' | '23' | '24' | '25' | '26', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(PlaySoundOnClickSchema).toReject(input, expectedError); + } else { + expect(PlaySoundOnClickSchema).toValidate(input); + } + }); + }); + + describe("PaceCaretSchema", () => { + it.each([ + { + description: "valid value 'daily'", + input: "daily", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'average' | 'pb' | 'tagPb' | 'last' | 'custom' | 'daily', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(PaceCaretSchema).toReject(input, expectedError); + } else { + expect(PaceCaretSchema).toValidate(input); + } + }); + }); + + describe("MinimumWordsPerMinuteSchema", () => { + it.each([ + { + description: "valid value 'custom'", + input: "custom", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'custom', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(MinimumWordsPerMinuteSchema).toReject(input, expectedError); + } else { + expect(MinimumWordsPerMinuteSchema).toValidate(input); + } + }); + }); + + describe("HighlightModeSchema", () => { + it.each([ + { + description: "valid value 'next_three_words'", + input: "next_three_words", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'letter' | 'word' | 'next_word' | 'next_two_words' | 'next_three_words', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(HighlightModeSchema).toReject(input, expectedError); + } else { + expect(HighlightModeSchema).toValidate(input); + } + }); + }); + + describe("TypedEffectSchema", () => { + it.each([ + { + description: "valid value 'dots'", + input: "dots", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'keep' | 'hide' | 'fade' | 'dots', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(TypedEffectSchema).toReject(input, expectedError); + } else { + expect(TypedEffectSchema).toValidate(input); + } + }); + }); + + describe("TapeModeSchema", () => { + it.each([ + { + description: "valid value 'word'", + input: "word", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'letter' | 'word', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(TapeModeSchema).toReject(input, expectedError); + } else { + expect(TapeModeSchema).toValidate(input); + } + }); + }); + + describe("TypingSpeedUnitSchema", () => { + it.each([ + { + description: "valid value 'wph'", + input: "wph", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'wpm' | 'cpm' | 'wps' | 'cps' | 'wph', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(TypingSpeedUnitSchema).toReject(input, expectedError); + } else { + expect(TypingSpeedUnitSchema).toValidate(input); + } + }); + }); + + describe("AdsSchema", () => { + it.each([ + { + description: "valid value 'sellout'", + input: "sellout", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'result' | 'on' | 'sellout', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(AdsSchema).toReject(input, expectedError); + } else { + expect(AdsSchema).toValidate(input); + } + }); + }); + + describe("MinimumAccuracySchema", () => { + it.each([ + { + description: "valid value 'custom'", + input: "custom", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'custom', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(MinimumAccuracySchema).toReject(input, expectedError); + } else { + expect(MinimumAccuracySchema).toValidate(input); + } + }); + }); + + describe("RepeatQuotesSchema", () => { + it.each([ + { + description: "valid value 'typing'", + input: "typing", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'typing', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(RepeatQuotesSchema).toReject(input, expectedError); + } else { + expect(RepeatQuotesSchema).toValidate(input); + } + }); + }); + + describe("OppositeShiftModeSchema", () => { + it.each([ + { + description: "valid value 'keymap'", + input: "keymap", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'on' | 'keymap', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(OppositeShiftModeSchema).toReject(input, expectedError); + } else { + expect(OppositeShiftModeSchema).toValidate(input); + } + }); + }); + + describe("CustomBackgroundSizeSchema", () => { + it.each([ + { + description: "valid value 'cover'", + input: "cover", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'cover' | 'contain' | 'max', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CustomBackgroundSizeSchema).toReject(input, expectedError); + } else { + expect(CustomBackgroundSizeSchema).toValidate(input); + } + }); + }); + + describe("MonkeyPowerLevelSchema", () => { + it.each([ + { + description: "valid value '3'", + input: "3", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | '1' | '2' | '3' | '4', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(MonkeyPowerLevelSchema).toReject(input, expectedError); + } else { + expect(MonkeyPowerLevelSchema).toValidate(input); + } + }); + }); + + describe("MinimumBurstSchema", () => { + it.each([ + { + description: "valid value 'fixed'", + input: "fixed", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'fixed' | 'flex', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(MinimumBurstSchema).toReject(input, expectedError); + } else { + expect(MinimumBurstSchema).toValidate(input); + } + }); + }); + + describe("ShowAverageSchema", () => { + it.each([ + { + description: "valid value 'speed'", + input: "speed", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | 'speed' | 'acc' | 'both', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ShowAverageSchema).toReject(input, expectedError); + } else { + expect(ShowAverageSchema).toValidate(input); + } + }); + }); + + describe("FunboxNameSchema", () => { + it.each([ + { + description: "valid value 'mirror'", + input: "mirror", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected '58008' | 'mirror' | 'upside_down' | 'nausea' | 'round_round_baby' | 'simon_says' | 'tts' | 'choo_choo' | 'arrows' | 'rAnDoMcAsE' | 'sPoNgEcAsE' | 'capitals' | 'layout_mirror' | 'layoutfluid' | 'earthquake' | 'space_balls' | 'gibberish' | 'ascii' | 'specials' | 'plus_zero' | 'plus_one' | 'plus_two' | 'plus_three' | 'read_ahead_easy' | 'read_ahead' | 'read_ahead_hard' | 'memory' | 'nospace' | 'poetry' | 'wikipedia' | 'weakspot' | 'pseudolang' | 'IPv4' | 'IPv6' | 'binary' | 'hexadecimal' | 'zipf' | 'morse' | 'crt' | 'backwards' | 'ddoouubblleedd' | 'instant_messaging' | 'underscore_spaces' | 'ALL_CAPS' | 'polyglot' | 'asl' | 'rot13' | 'no_quit', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(FunboxNameSchema).toReject(input, expectedError); + } else { + expect(FunboxNameSchema).toValidate(input); + } + }); + }); + + describe("PlayTimeWarningSchema", () => { + it.each([ + { + description: "valid value '5'", + input: "5", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'off' | '1' | '3' | '5' | '10', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(PlayTimeWarningSchema).toReject(input, expectedError); + } else { + expect(PlayTimeWarningSchema).toValidate(input); + } + }); + }); + + describe("ConfigGroupNameSchema", () => { + it.each([ + { + description: "valid value 'appearance'", + input: "appearance", + }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'test' | 'behavior' | 'input' | 'sound' | 'caret' | 'appearance' | 'theme' | 'hideElements' | 'hidden' | 'ads', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ConfigGroupNameSchema).toReject(input, expectedError); + } else { + expect(ConfigGroupNameSchema).toValidate(input); + } + }); + }); + + describe("CustomBackgroundSchema", () => { + it.each([ + { + description: "http", + input: `http://example.com/path/image.png`, + }, + { + description: "https", + input: `https://example.com/path/image.png`, + }, + { + description: "png", + input: `https://example.com/path/image.png`, + }, + { + description: "gif", + input: `https://example.com/path/image.gif?width=5`, + }, + { + description: "jpeg", + input: `https://example.com/path/image.jpeg`, + }, + { + description: "jpg", + input: `https://example.com/path/image.jpg`, + }, + { + description: "tiff", + input: `https://example.com/path/image.tiff`, + expectedError: "Unsupported image format", + }, + { + description: "non-url", + input: `test`, + expectedError: "Needs to be an URI", + }, + { + description: "single quotes", + input: `https://example.com/404.jpg?q=alert('1')`, + expectedError: "May not contain quotes", + }, + { + description: "double quotes", + input: `https://example.com/404.jpg?q=alert("1")`, + expectedError: "May not contain quotes", + }, + { + description: "back tick", + input: `https://example.com/404.jpg?q=alert(\`1\`)`, + expectedError: "May not contain quotes", + }, + { + description: "javascript url", + input: `javascript:alert('asdf');//https://example.com/img.jpg`, + expectedError: "Unsupported protocol", + }, + { + description: "data url", + input: `data:image/gif;base64,data`, + expectedError: "Unsupported protocol", + }, + { + description: "long url", + input: `https://example.com/path/image.jpeg?q=${new Array(2048) + .fill("x") + .join()}`, + expectedError: "URL is too long", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CustomBackgroundSchema).toReject(input, expectedError); + } else { + expect(CustomBackgroundSchema).toValidate(input); + } + }); + }); + + describe("QuoteLengthConfigSchema", () => { + it.each([ + { + description: "valid array with one value", + input: [-3], + }, + { + description: "valid array with multiple values", + input: [0, 1, 2], + }, + { + description: "invalid value", + input: [4], + expectedError: "Invalid input", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(QuoteLengthConfigSchema).toReject(input, expectedError); + } else { + expect(QuoteLengthConfigSchema).toValidate(input); + } + }); + }); + + describe("KeymapSizeSchema", () => { + it.each([ + { description: "valid value 1.0", input: 1.0 }, + { description: "valid min value 0.5", input: 0.5 }, + { description: "valid max value 3.5", input: 3.5 }, + { + description: "invalid min below range", + input: 0.4, + expectedError: "Number must be greater than or equal to 0.5", + }, + { + description: "invalid max above range", + input: 3.6, + expectedError: "Number must be less than or equal to 3.5", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(KeymapSizeSchema).toReject(input, expectedError); + } else { + expect(KeymapSizeSchema).toValidate(input); + } + }); + }); + + describe("SoundVolumeSchema", () => { + it.each([ + { description: "valid 0", input: 0 }, + { description: "valid 0.5", input: 0.5 }, + { description: "valid 1", input: 1 }, + { + description: "invalid below range", + input: -0.1, + expectedError: "Number must be greater than or equal to 0", + }, + { + description: "invalid above range", + input: 1.1, + expectedError: "Number must be less than or equal to 1", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(SoundVolumeSchema).toReject(input, expectedError); + } else { + expect(SoundVolumeSchema).toValidate(input); + } + }); + }); + + describe("AccountChartSchema", () => { + it.each([ + { description: "valid all on", input: ["on", "on", "on", "on"] }, + { description: "valid mixed values", input: ["off", "on", "off", "on"] }, + { + description: "invalid value", + input: ["on", "yes", "no", "off"], + expectedError: "Invalid enum value", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(AccountChartSchema).toReject(input, expectedError); + } else { + expect(AccountChartSchema).toValidate(input); + } + }); + }); + + describe("TapeMarginSchema", () => { + it.each([ + { description: "valid min 10", input: 10 }, + { description: "valid middle 50", input: 50 }, + { description: "valid max 90", input: 90 }, + { + description: "invalid below range", + input: 5, + expectedError: "Number must be greater than or equal to 10", + }, + { + description: "invalid above range", + input: 95, + expectedError: "Number must be less than or equal to 90", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(TapeMarginSchema).toReject(input, expectedError); + } else { + expect(TapeMarginSchema).toValidate(input); + } + }); + }); + + describe("CustomBackgroundFilterSchema", () => { + it.each([ + { description: "valid tuple [0, 0, 0, 0]", input: [0, 0, 0, 0] }, + { + description: "valid tuple [100, 50, 25, 75]", + input: [100, 50, 25, 75], + }, + { + description: "invalid - too few items", + input: [0, 0, 0], + expectedError: "Array must contain at least 4 element(s)", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CustomBackgroundFilterSchema).toReject(input, expectedError); + } else { + expect(CustomBackgroundFilterSchema).toValidate(input); + } + }); + }); + + describe("CustomLayoutFluidSchema", () => { + it.each([ + { description: "valid array with 2 items", input: ["dvorak", "colemak"] }, + { + description: "valid array with 15 items", + input: [ + "dvorak", + "colemak", + "qwerty", + "azerty", + "qwertz", + "dvorak", + "colemak", + "qwerty", + "azerty", + "qwertz", + "dvorak", + "colemak", + "qwerty", + "azerty", + "qwertz", + ], + }, + { + description: "invalid array with 1 item", + input: ["qwerty"], + expectedError: "Array must contain at least 2 element(s)", + }, + { + description: "invalid array with 16 items", + input: [ + "qwerty", + "azerty", + "qwertz", + "dvorak", + "colemak", + "qwerty", + "azerty", + "qwertz", + "dvorak", + "colemak", + "qwerty", + "azerty", + "qwertz", + "dvorak", + "colemak", + "qwerty", + ], + expectedError: "Array must contain at most 15 element(s)", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CustomLayoutFluidSchema).toReject(input, expectedError); + } else { + expect(CustomLayoutFluidSchema).toValidate(input); + } + }); + }); + + describe("CustomPolyglotSchema", () => { + it.each([ + { + description: "valid array with 2 languages", + input: ["english", "spanish"], + }, + { + description: "invalid array with 1 language", + input: ["english"], + expectedError: "Array must contain at least 2 element(s)", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CustomPolyglotSchema).toReject(input, expectedError); + } else { + expect(CustomPolyglotSchema).toValidate(input); + } + }); + }); + + describe("ShowPbSchema", () => { + it.each([ + { description: "valid true", input: true }, + { description: "valid false", input: false }, + ] as const)("$description", ({ input }) => { + expect(ShowPbSchema).toValidate(input); + }); + }); + + describe("ColorHexValueSchema", () => { + it.each([ + { description: "valid short #fff", input: "#fff" }, + { description: "valid long #ffffff", input: "#ffffff" }, + { description: "valid uppercase #ABC123", input: "#ABC123" }, + { + description: "invalid - missing #", + input: "ffffff", + expectedError: "Invalid", + }, + { + description: "invalid - wrong format", + input: "#gggggg", + expectedError: "Invalid", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ColorHexValueSchema).toReject(input, expectedError); + } else { + expect(ColorHexValueSchema).toValidate(input); + } + }); + }); + + describe("CustomThemeColorsSchema", () => { + it.each([ + { + description: "valid tuple of 10 colors", + input: [ + "#ffffff", + "#000000", + "#ff0000", + "#00ff00", + "#0000ff", + "#ffff00", + "#ff00ff", + "#00ffff", + "#123456", + "#abcdef", + ], + }, + { + description: "invalid - too few items", + input: ["#ffffff"], + expectedError: "Array must contain at least 10 element(s)", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CustomThemeColorsSchema).toReject(input, expectedError); + } else { + expect(CustomThemeColorsSchema).toValidate(input); + } + }); + }); + + describe("FavThemesSchema", () => { + it.each([ + { description: "valid empty array", input: [] }, + { description: "valid single theme", input: ["dracula"] }, + { + description: "valid multiple themes", + input: ["dracula", "rose_pine", "monokai"], + }, + ] as const)("$description", ({ input }) => { + expect(FavThemesSchema).toValidate(input); + }); + }); + + describe("FunboxSchema", () => { + it.each([ + { description: "valid empty array", input: [] }, + { description: "valid single funbox", input: ["mirror"] }, + { + description: "valid multiple funboxes", + input: ["mirror", "upside_down"], + }, + { description: "valid 15 funboxes", input: Array(15).fill("mirror") }, + { + description: "invalid 16 funboxes", + input: Array(16).fill("mirror"), + expectedError: "Array must contain at most 15 element(s)", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(FunboxSchema).toReject(input, expectedError); + } else { + expect(FunboxSchema).toValidate(input); + } + }); + }); + + describe("PaceCaretCustomSpeedSchema", () => { + it.each([ + { description: "valid 0", input: 0 }, + { description: "valid positive number", input: 100 }, + { + description: "invalid negative", + input: -1, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(PaceCaretCustomSpeedSchema).toReject(input, expectedError); + } else { + expect(PaceCaretCustomSpeedSchema).toValidate(input); + } + }); + }); + + describe("MinWpmCustomSpeedSchema", () => { + it.each([ + { description: "valid 0", input: 0 }, + { description: "valid positive number", input: 80 }, + { + description: "invalid negative", + input: -1, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(MinWpmCustomSpeedSchema).toReject(input, expectedError); + } else { + expect(MinWpmCustomSpeedSchema).toValidate(input); + } + }); + }); + + describe("MinimumAccuracyCustomSchema", () => { + it.each([ + { description: "valid 0", input: 0 }, + { description: "valid 50", input: 50 }, + { description: "valid max 100", input: 100 }, + { + description: "invalid negative", + input: -1, + expectedError: "Number must be greater than or equal to 0", + }, + { + description: "invalid above max", + input: 150, + expectedError: "Number must be less than or equal to 100", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(MinimumAccuracyCustomSchema).toReject(input, expectedError); + } else { + expect(MinimumAccuracyCustomSchema).toValidate(input); + } + }); + }); + + describe("MinimumBurstCustomSpeedSchema", () => { + it.each([ + { description: "valid 0", input: 0 }, + { description: "valid positive number", input: 100 }, + { + description: "invalid negative", + input: -1, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(MinimumBurstCustomSpeedSchema).toReject(input, expectedError); + } else { + expect(MinimumBurstCustomSpeedSchema).toValidate(input); + } + }); + }); + + describe("TimeConfigSchema", () => { + it.each([ + { description: "valid 0", input: 0 }, + { description: "valid positive number", input: 30 }, + { + description: "invalid negative", + input: -1, + expectedError: "Number must be greater than or equal to 0", + }, + { + description: "invalid decimal", + input: 30.5, + expectedError: "Expected integer, received float", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(TimeConfigSchema).toReject(input, expectedError); + } else { + expect(TimeConfigSchema).toValidate(input); + } + }); + }); + + describe("WordCountSchema", () => { + it.each([ + { description: "valid 0", input: 0 }, + { description: "valid positive number", input: 100 }, + { + description: "invalid negative", + input: -1, + expectedError: "Number must be greater than or equal to 0", + }, + { + description: "invalid decimal", + input: 10.5, + expectedError: "Expected integer, received float", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(WordCountSchema).toReject(input, expectedError); + } else { + expect(WordCountSchema).toValidate(input); + } + }); + }); + + describe("KeymapLayoutSchema", () => { + it.each([ + { description: "valid overrideSync", input: "overrideSync" }, + { description: "valid layout name", input: "qwerty" }, + ] as const)("$description", ({ input }) => { + expect(KeymapLayoutSchema).toValidate(input); + }); + }); + + describe("LayoutSchema", () => { + it.each([ + { description: "valid default", input: "default" }, + { description: "valid layout name", input: "qwerty" }, + ] as const)("$description", ({ input }) => { + expect(LayoutSchema).toValidate(input); + }); + }); + + describe("FontSizeSchema", () => { + it.each([ + { description: "valid positive number", input: 12 }, + { description: "valid small positive", input: 0.5 }, + { + description: "invalid zero", + input: 0, + expectedError: "Number must be greater than 0", + }, + { + description: "invalid negative", + input: -1, + expectedError: "Number must be greater than 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(FontSizeSchema).toReject(input, expectedError); + } else { + expect(FontSizeSchema).toValidate(input); + } + }); + }); + + describe("MaxLineWidthSchema", () => { + it.each([ + { description: "valid min 20", input: 20 }, + { description: "valid middle value", input: 500 }, + { description: "valid max 1000", input: 1000 }, + { description: "valid zero (no limit)", input: 0 }, + { + description: "invalid below min", + input: 19, + expectedError: "Number must be greater than or equal to 20", + }, + { + description: "invalid above max", + input: 1001, + expectedError: "Number must be less than or equal to 1000", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(MaxLineWidthSchema).toReject(input, expectedError); + } else { + expect(MaxLineWidthSchema).toValidate(input); + } + }); + }); + + describe("ConfigSchema", () => { + it.each([ + { + description: "valid minimal config", + input: { + punctuation: false, + numbers: false, + words: 10, + time: 15, + mode: "time", + quoteLength: [], + language: "english", + burstHeatmap: false, + difficulty: "normal", + quickRestart: "off", + repeatQuotes: "off", + resultSaving: true, + blindMode: false, + alwaysShowWordsHistory: false, + singleListCommandLine: "manual", + minWpm: "off", + minWpmCustomSpeed: 0, + minAcc: "off", + minAccCustom: 0, + minBurst: "off", + minBurstCustomSpeed: 0, + britishEnglish: false, + funbox: [], + customLayoutfluid: ["qwerty", "colemak"], + customPolyglot: ["english", "spanish"], + freedomMode: false, + strictSpace: false, + oppositeShiftMode: "off", + stopOnError: "off", + confidenceMode: "off", + quickEnd: false, + indicateTypos: "off", + compositionDisplay: "off", + hideExtraLetters: false, + lazyMode: false, + layout: "default", + codeUnindentOnBackspace: false, + soundVolume: 1, + playSoundOnClick: "off", + playSoundOnError: "off", + playTimeWarning: "off", + smoothCaret: "off", + caretStyle: "default", + paceCaret: "off", + paceCaretCustomSpeed: 0, + paceCaretStyle: "default", + repeatedPace: false, + timerStyle: "bar", + liveSpeedStyle: "off", + liveAccStyle: "off", + liveBurstStyle: "off", + timerColor: "black", + timerOpacity: "1", + highlightMode: "off", + typedEffect: "keep", + tapeMode: "off", + tapeMargin: 50, + smoothLineScroll: false, + showAllLines: false, + alwaysShowDecimalPlaces: false, + typingSpeedUnit: "wpm", + startGraphsAtZero: true, + maxLineWidth: 500, + fontSize: 16, + fontFamily: "terranova", + keymapMode: "off", + keymapLayout: "overrideSync", + keymapStyle: "staggered", + keymapLegendStyle: "lowercase", + keymapShowTopRow: "always", + keymapSize: 1, + flipTestColors: false, + colorfulMode: false, + customBackground: "", + customBackgroundSize: "cover", + customBackgroundFilter: [0, 0, 0, 0], + autoSwitchTheme: false, + themeLight: "dracula", + themeDark: "rose_pine", + randomTheme: "off", + favThemes: [], + theme: "dark", + customTheme: false, + customThemeColors: Array(10).fill("#ffffff"), + showKeyTips: false, + showOutOfFocusWarning: true, + capsLockWarning: false, + showAverage: "off", + showPb: false, + accountChart: ["off", "off", "off", "off"], + monkey: false, + monkeyPowerLevel: "off", + ads: "off", + }, + }, + ] as const)("$description", ({ input }) => { + expect(ConfigSchema).toValidate(input); + }); + }); + + describe("ConfigKeySchema", () => { + it.each([ + { description: "valid key punctuation", input: "punctuation" }, + { description: "valid key time", input: "time" }, + { + description: "invalid key", + input: "invalid_key", + expectedError: "Invalid enum value", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ConfigKeySchema).toReject(input, expectedError); + } else { + expect(ConfigKeySchema).toValidate(input); + } + }); + }); + + describe("PartialConfigSchema", () => { + it.each([ + { + description: "valid partial config", + input: { punctuation: true }, + }, + ] as const)("$description", ({ input }) => { + expect(PartialConfigSchema).toValidate(input); + }); + }); +}); diff --git a/packages/schemas/__tests__/configuration.spec.ts b/packages/schemas/__tests__/configuration.spec.ts new file mode 100644 index 000000000000..9c41c0810742 --- /dev/null +++ b/packages/schemas/__tests__/configuration.spec.ts @@ -0,0 +1,322 @@ +import { it, expect, describe } from "vitest"; +import { + ValidModeRuleSchema, + RewardBracketSchema, + ConfigurationSchema, +} from "../src/configuration"; + +// Constants for large configuration objects +const minimalConfiguration = { + maintenance: false, + dev: { responseSlowdownMs: 0 }, +}; + +const disabledConfiguration = { + ...minimalConfiguration, + quotes: { + reporting: { enabled: false, maxReports: 0, contentReportLimit: 0 }, + submissionsEnabled: false, + maxFavorites: 0, + }, + results: { + savingEnabled: false, + objectHashCheckEnabled: false, + filterPresets: { enabled: false, maxPresetsPerUser: 0 }, + limits: { regularUser: 0, premiumUser: 0 }, + maxBatchSize: 0, + }, + users: { + signUp: false, + lastHashesCheck: { enabled: false, maxHashes: 0 }, + autoBan: { enabled: false, maxCount: 0, maxHours: 0 }, + profiles: { enabled: false }, + discordIntegration: { enabled: false }, + xp: { + enabled: false, + funboxBonus: 0, + gainMultiplier: 0, + maxDailyBonus: 0, + minDailyBonus: 0, + streak: { + enabled: false, + maxStreakDays: 0, + maxStreakMultiplier: 0, + }, + }, + inbox: { enabled: false, maxMail: 0 }, + premium: { enabled: false }, + }, + admin: { endpointsEnabled: false }, + apeKeys: { + endpointsEnabled: false, + acceptKeys: false, + maxKeysPerUser: 0, + apeKeyBytes: 0, + apeKeySaltRounds: 0, + }, + rateLimiting: { + badAuthentication: { + enabled: false, + penalty: 0, + flaggedStatusCodes: [], + }, + }, + dailyLeaderboards: { + enabled: false, + leaderboardExpirationTimeInDays: 0, + maxResults: 0, + validModeRules: [], + scheduleRewardsModeRules: [], + topResultsToAnnounce: 0, + xpRewardBrackets: [], + }, + leaderboards: { + minTimeTyping: 0, + weeklyXp: { + enabled: false, + expirationTimeInDays: 0, + xpRewardBrackets: [], + }, + }, + connections: { enabled: false, maxPerUser: 0 }, +}; + +const fullConfiguration = { + maintenance: false, + dev: { responseSlowdownMs: 0 }, + quotes: { + reporting: { + enabled: true, + maxReports: 5, + contentReportLimit: 3, + }, + submissionsEnabled: true, + maxFavorites: 100, + }, + results: { + savingEnabled: true, + objectHashCheckEnabled: true, + filterPresets: { + enabled: true, + maxPresetsPerUser: 10, + }, + limits: { + regularUser: 50, + premiumUser: 200, + }, + maxBatchSize: 100, + }, + users: { + signUp: true, + lastHashesCheck: { + enabled: true, + maxHashes: 5, + }, + autoBan: { + enabled: true, + maxCount: 3, + maxHours: 24, + }, + profiles: { + enabled: true, + }, + discordIntegration: { + enabled: true, + }, + xp: { + enabled: true, + funboxBonus: 1.5, + gainMultiplier: 1, + maxDailyBonus: 1000, + minDailyBonus: 100, + streak: { + enabled: true, + maxStreakDays: 30, + maxStreakMultiplier: 2, + }, + }, + inbox: { + enabled: true, + maxMail: 50, + }, + premium: { + enabled: true, + }, + }, + admin: { endpointsEnabled: true }, + apeKeys: { + endpointsEnabled: true, + acceptKeys: true, + maxKeysPerUser: 5, + apeKeyBytes: 32, + apeKeySaltRounds: 10, + }, + rateLimiting: { + badAuthentication: { + enabled: true, + penalty: 60, + flaggedStatusCodes: [401, 403], + }, + }, + dailyLeaderboards: { + enabled: true, + leaderboardExpirationTimeInDays: 1, + maxResults: 500, + validModeRules: [{ language: "english", mode: "time", mode2: "30" }], + scheduleRewardsModeRules: [ + { language: "english", mode: "time", mode2: "(15|60)" }, + ], + topResultsToAnnounce: 3, + xpRewardBrackets: [ + { + minRank: 1, + maxRank: 10, + minReward: 100, + maxReward: 500, + }, + ], + }, + leaderboards: { + minTimeTyping: 10, + weeklyXp: { + enabled: true, + expirationTimeInDays: 7, + xpRewardBrackets: [ + { + minRank: 1, + maxRank: 5, + minReward: 200, + maxReward: 1000, + }, + ], + }, + }, + connections: { + enabled: true, + maxPerUser: 3, + }, +}; + +describe("configuration schemas", () => { + describe("ValidModeRuleSchema", () => { + it.each([ + { + description: "valid mode rule", + input: { + language: "english", + mode: "time", + mode2: "30", + }, + }, + { + description: "missing mode", + input: { + language: "english", + mode2: "30", + }, + expectedError: "Required", + }, + { + description: "extra field", + input: { + language: "english", + mode: "time", + mode2: "30", + extra: true, + }, + expectedError: "Unrecognized key(s)", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ValidModeRuleSchema).toReject(input, expectedError); + } else { + expect(ValidModeRuleSchema).toValidate(input); + } + }); + }); + + describe("RewardBracketSchema", () => { + it.each([ + { + description: "valid reward bracket", + input: { + minRank: 1, + maxRank: 10, + minReward: 100, + maxReward: 500, + }, + }, + { + description: "zero values are valid", + input: { + minRank: 0, + maxRank: 0, + minReward: 0, + maxReward: 0, + }, + }, + { + description: "negative minRank", + input: { ...disabledConfiguration, minRank: -1 }, + expectedError: "Number must be greater than or equal to 0", + }, + { + description: "non-integer value", + input: { ...disabledConfiguration, minRank: 1.5 }, + expectedError: "Expected integer, received float", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(RewardBracketSchema).toReject(input, expectedError); + } else { + expect(RewardBracketSchema).toValidate(input); + } + }); + }); + + describe("ConfigurationSchema", () => { + it.each([ + { + description: "valid full configuration", + input: fullConfiguration, + }, + { + description: "maintenance as true", + input: { ...fullConfiguration, maintenance: true }, + }, + { + description: "missing required top-level field", + input: { ...minimalConfiguration, quotes: undefined } as any, + expectedError: "Required", + }, + { + description: "topResultsToAnnounce cannot be zero", + input: { + ...fullConfiguration, + dailyLeaderboards: { + ...fullConfiguration.dailyLeaderboards, + topResultsToAnnounce: 0, + }, + }, + expectedError: "Number must be greater than 0", + }, + { + description: "minTimeTyping cannot be negative", + input: { + ...fullConfiguration, + leaderboards: { + ...fullConfiguration.leaderboards, + minTimeTyping: -1, + }, + }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ConfigurationSchema).toReject(input, expectedError); + } else { + expect(ConfigurationSchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/connections.spec.ts b/packages/schemas/__tests__/connections.spec.ts new file mode 100644 index 000000000000..d9b8f7ecaeb5 --- /dev/null +++ b/packages/schemas/__tests__/connections.spec.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { + ConnectionStatusSchema, + ConnectionTypeSchema, + ConnectionSchema, +} from "../src/connections"; + +describe("ConnectionStatusSchema", () => { + it.each([ + { description: "valid status: pending", input: "pending" }, + { description: "valid status: accepted", input: "accepted" }, + { description: "valid status: blocked", input: "blocked" }, + { + description: "invalid status", + input: "unknown", + expectedError: "Invalid enum value", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ConnectionStatusSchema).toReject(input, expectedError); + } else { + expect(ConnectionStatusSchema).toValidate(input); + } + }); +}); + +describe("ConnectionTypeSchema", () => { + it.each([ + { description: "valid type: incoming", input: "incoming" }, + { description: "valid type: outgoing", input: "outgoing" }, + { + description: "invalid type", + input: "unknown", + expectedError: "Invalid enum value", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ConnectionTypeSchema).toReject(input, expectedError); + } else { + expect(ConnectionTypeSchema).toValidate(input); + } + }); +}); + +describe("ConnectionSchema", () => { + it.each([ + { + description: "valid connection", + input: { + _id: "abc_123", + initiatorUid: "user_1", + initiatorName: "Alice", + receiverUid: "user_2", + receiverName: "Bob", + lastModified: 1700000000, + status: "pending", + }, + }, + { + description: "invalid status", + input: { + _id: "abc_123", + initiatorUid: "user_1", + initiatorName: "Alice", + receiverUid: "user_2", + receiverName: "Bob", + lastModified: 1700000000, + status: "unknown", + }, + expectedError: "Invalid enum value", + }, + { + description: "negative lastModified", + input: { + _id: "abc_123", + initiatorUid: "user_1", + initiatorName: "Alice", + receiverUid: "user_2", + receiverName: "Bob", + lastModified: -1, + status: "pending", + }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ConnectionSchema).toReject(input, expectedError); + } else { + expect(ConnectionSchema).toValidate(input); + } + }); +}); diff --git a/packages/schemas/__tests__/fonts.spec.ts b/packages/schemas/__tests__/fonts.spec.ts new file mode 100644 index 000000000000..ca1f1045ea02 --- /dev/null +++ b/packages/schemas/__tests__/fonts.spec.ts @@ -0,0 +1,32 @@ +import { it, expect, describe } from "vitest"; +import { FontNameSchema } from "../src/fonts"; + +describe("fonts schemas", () => { + describe("FontNameSchema", () => { + it.each([ + { description: "valid known font Roboto_Mono", input: "Roboto_Mono" }, + { description: "valid known font Inter_Tight", input: "Inter_Tight" }, + { + description: "valid custom font with underscore", + input: "Custom_Font", + }, + { description: "valid custom font with hyphen", input: "Custom-Font" }, + { + description: "invalid font with space", + input: "Custom Font", + expectedError: "Invalid", + }, + { + description: "invalid font exceeds max length 50", + input: "a".repeat(51), + expectedError: "String must contain at most 50 character", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(FontNameSchema).toReject(input, expectedError); + } else { + expect(FontNameSchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/languages.spec.ts b/packages/schemas/__tests__/languages.spec.ts new file mode 100644 index 000000000000..f6049867cc74 --- /dev/null +++ b/packages/schemas/__tests__/languages.spec.ts @@ -0,0 +1,55 @@ +import { it, expect, describe } from "vitest"; +import { LanguageSchema, LanguageObjectSchema } from "../src/languages"; + +describe("languages schemas", () => { + describe("LanguageSchema", () => { + it.each([ + { description: "valid language english", input: "english" }, + { description: "valid language spanish", input: "spanish" }, + { + description: "invalid language", + input: "invalid_language", + expectedError: "Must be a supported language", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(LanguageSchema).toReject(input, expectedError); + } else { + expect(LanguageSchema).toValidate(input); + } + }); + }); + + describe("LanguageObjectSchema", () => { + it.each([ + { + description: "valid language object", + input: { + name: "english", + words: ["hello", "world"], + }, + }, + { + description: "invalid - missing name", + input: { words: ["hello", "world"] }, + expectedError: "Required", + }, + { + description: "invalid - missing words", + input: { name: "english" }, + expectedError: "Required", + }, + { + description: "invalid - extra property", + input: { name: "english", words: ["hello"], extra: true }, + expectedError: "Unrecognized key", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(LanguageObjectSchema).toReject(input, expectedError); + } else { + expect(LanguageObjectSchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/layouts.spec.ts b/packages/schemas/__tests__/layouts.spec.ts new file mode 100644 index 000000000000..b1e22524ec49 --- /dev/null +++ b/packages/schemas/__tests__/layouts.spec.ts @@ -0,0 +1,211 @@ +import { it, expect, describe } from "vitest"; +import { LayoutNameSchema, LayoutObjectSchema } from "../src/layouts"; + +const validAnsILayout = { + keymapShowTopRow: true, + matrixShowRightColumn: false, + type: "ansi", + keys: { + row1: [ + ["`", "~"], + ["1", "!"], + ["2", "@"], + ["3", "#"], + ["4", "$"], + ["5", "%"], + ["6", "^"], + ["7", "&"], + ["8", "*"], + ["9", "("], + ["0", ")"], + ["-", "_"], + ["=", "+"], + ], + row2: [ + ["q", "Q"], + ["w", "W"], + ["e", "E"], + ["r", "R"], + ["t", "T"], + ["y", "Y"], + ["u", "U"], + ["i", "I"], + ["o", "O"], + ["p", "P"], + ["[", "{"], + ["]", "}"], + ["\\", "|"], + ], + row3: [ + ["a", "A"], + ["s", "S"], + ["d", "D"], + ["f", "F"], + ["g", "G"], + ["h", "H"], + ["j", "J"], + ["k", "K"], + ["l", "L"], + [";", ":"], + ["'", '"'], + ], + row4: [ + ["z", "Z"], + ["x", "X"], + ["c", "C"], + ["v", "V"], + ["b", "B"], + ["n", "N"], + ["m", "M"], + [",", "<"], + [".", ">"], + ["/", "?"], + ], + row5: [[" "]], + }, +}; + +const validIsoLayout = { + keymapShowTopRow: true, + matrixShowRightColumn: false, + type: "iso", + keys: { + row1: [ + ["\\", "|"], + ["1", "!"], + ["2", '"'], + ["3", "#"], + ["4", "$"], + ["5", "%"], + ["6", "&"], + ["7", "/"], + ["8", "("], + ["9", ")"], + ["0", "="], + ["'", "?"], + ["«", "»"], + ], + row2: [ + ["q", "Q"], + ["w", "W"], + ["e", "E"], + ["r", "R"], + ["t", "T"], + ["y", "Y"], + ["u", "U"], + ["i", "I"], + ["o", "O"], + ["p", "P"], + ["+", "*"], + ["´", "`"], + ], + row3: [ + ["a", "A"], + ["s", "S"], + ["d", "D"], + ["f", "F"], + ["g", "G"], + ["h", "H"], + ["j", "J"], + ["k", "K"], + ["l", "L"], + ["ç", "Ç"], + ["º", "ª"], + ["~", "^"], + ], + row4: [ + ["<", ">"], + ["z", "Z"], + ["x", "X"], + ["c", "C"], + ["v", "V"], + ["b", "B"], + ["n", "N"], + ["m", "M"], + [",", ";"], + [".", ":"], + ["-", "_"], + ], + row5: [[" "]], + }, +}; + +const validMatrixLayout = { + keymapShowTopRow: true, + matrixShowRightColumn: false, + type: "matrix", + keys: { + row1: [], + row2: [ + ["q", "Q"], + ["w", "W"], + ["e", "E"], + ["r", "R"], + ["t", "T"], + ["y", "Y"], + ["u", "U"], + ["i", "I"], + ["o", "O"], + ["p", "P"], + ], + row3: [ + ["a", "A"], + ["s", "S"], + ["d", "D"], + ["f", "F"], + ["g", "G"], + ["h", "H"], + ["j", "J"], + ["k", "K"], + ["l", "L"], + [";", ":"], + ], + row4: [ + ["z", "Z"], + ["x", "X"], + ["c", "C"], + ["v", "V"], + ], + row5: [], + }, +}; + +describe("layouts schemas", () => { + describe("LayoutObjectSchema", () => { + it.each([ + { description: "valid ansi layout", input: validAnsILayout }, + { description: "valid iso layout", input: validIsoLayout }, + { description: "valid matrix layout", input: validMatrixLayout }, + { + description: "invalid - missing required field", + input: { keymapShowTopRow: true }, + expectedError: "Invalid input", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(LayoutObjectSchema).toReject(input, expectedError); + } else { + expect(LayoutObjectSchema).toValidate(input); + } + }); + }); + + describe("LayoutNameSchema", () => { + it.each([ + { description: "valid layout qwerty", input: "qwerty" }, + { description: "valid layout dvorak", input: "dvorak" }, + { description: "valid layout colemak_dh", input: "colemak_dh" }, + { + description: "invalid layout", + input: "invalid_layout", + expectedError: "Must be a supported layout", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(LayoutNameSchema).toReject(input, expectedError); + } else { + expect(LayoutNameSchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/leaderboards.spec.ts b/packages/schemas/__tests__/leaderboards.spec.ts new file mode 100644 index 000000000000..e40061644a6c --- /dev/null +++ b/packages/schemas/__tests__/leaderboards.spec.ts @@ -0,0 +1,168 @@ +import { it, expect, describe } from "vitest"; +import { + LeaderboardEntrySchema, + RedisDailyLeaderboardEntrySchema, + RedisXpLeaderboardEntrySchema, + RedisXpLeaderboardScoreSchema, + XpLeaderboardEntrySchema, +} from "../src/leaderboards"; + +const validLeaderboardEntry = { + wpm: 100, + acc: 95, + timestamp: 1234567890, + raw: 105, + consistency: 90, + uid: "user123", + name: "Test User", + rank: 1, +}; + +const validRedisDailyLeaderboardEntry = { + wpm: 100, + acc: 95, + timestamp: 1234567890, + raw: 105, + uid: "user123", + name: "Test User", +}; + +const validRedisXpLeaderboardEntry = { + uid: "user123", + name: "Test User", + lastActivityTimestamp: 1234567890, + timeTypedSeconds: 3600, +}; + +const validRedisXpLeaderboardScore = 100; + +const validXpLeaderboardEntry = { + uid: "user123", + name: "Test User", + lastActivityTimestamp: 1234567890, + timeTypedSeconds: 3600, + totalXp: 1000, + rank: 1, +}; + +describe("leaderboards schemas", () => { + describe("LeaderboardEntrySchema", () => { + it.each([ + { description: "valid leaderboard entry", input: validLeaderboardEntry }, + { + description: "with optional fields", + input: { + ...validLeaderboardEntry, + discordId: "discord123", + badgeId: 1, + }, + }, + { + description: "invalid - negative wpm", + input: { ...validLeaderboardEntry, wpm: -1 }, + expectedError: "Number must be greater than or equal to 0", + }, + { + description: "invalid - acc exceeds 100", + input: { ...validLeaderboardEntry, acc: 101 }, + expectedError: "Number must be less than or equal to 100", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(LeaderboardEntrySchema).toReject(input, expectedError); + } else { + expect(LeaderboardEntrySchema).toValidate(input); + } + }); + }); + + describe("RedisDailyLeaderboardEntrySchema", () => { + it.each([ + { + description: "valid redis daily leaderboard entry", + input: validRedisDailyLeaderboardEntry, + }, + { + description: "invalid - missing uid", + input: { ...validRedisDailyLeaderboardEntry, uid: undefined }, + expectedError: "Required", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(RedisDailyLeaderboardEntrySchema).toReject(input, expectedError); + } else { + expect(RedisDailyLeaderboardEntrySchema).toValidate(input); + } + }); + }); + + describe("RedisXpLeaderboardEntrySchema", () => { + it.each([ + { + description: "valid redis xp leaderboard entry", + input: validRedisXpLeaderboardEntry, + expectedError: undefined, + }, + { + description: "with discordId and discordAvatar", + input: { + ...validRedisXpLeaderboardEntry, + discordId: "discord123", + discordAvatar: "avatar.png", + }, + expectedError: undefined, + }, + { + description: "with null discordId (transformed to undefined)", + input: { + ...validRedisXpLeaderboardEntry, + discordId: null as unknown as string | undefined, + }, + expectedError: undefined, + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(RedisXpLeaderboardEntrySchema).toReject(input, expectedError); + } else { + expect(RedisXpLeaderboardEntrySchema).toValidate(input); + } + }); + }); + + describe("RedisXpLeaderboardScoreSchema", () => { + it.each([ + { description: "valid score", input: validRedisXpLeaderboardScore }, + { + description: "invalid - negative score", + input: -1, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(RedisXpLeaderboardScoreSchema).toReject(input, expectedError); + } else { + expect(RedisXpLeaderboardScoreSchema).toValidate(input); + } + }); + }); + + describe("XpLeaderboardEntrySchema", () => { + it.each([ + { + description: "valid xp leaderboard entry", + input: validXpLeaderboardEntry, + }, + { + description: "invalid - negative totalXp", + input: { ...validXpLeaderboardEntry, totalXp: -1 }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(XpLeaderboardEntrySchema).toReject(input, expectedError); + } else { + expect(XpLeaderboardEntrySchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/presets.spec.ts b/packages/schemas/__tests__/presets.spec.ts new file mode 100644 index 000000000000..2f9655915777 --- /dev/null +++ b/packages/schemas/__tests__/presets.spec.ts @@ -0,0 +1,107 @@ +import { it, expect, describe } from "vitest"; +import { + PresetNameSchema, + PresetTypeSchema, + PresetSchema, + EditPresetRequestSchema, +} from "../src/presets"; + +describe("presets schemas", () => { + describe("PresetNameSchema", () => { + it.each([ + { description: "valid preset name", input: "my_preset" }, + { + description: "invalid preset name too long", + input: "a".repeat(17), + expectedError: "String must contain at most 16 character(s)", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(PresetNameSchema).toReject(input, expectedError); + } else { + expect(PresetNameSchema).toValidate(input); + } + }); + }); + + describe("PresetTypeSchema", () => { + it.each([ + { description: "valid type full", input: "full" }, + { description: "valid type partial", input: "partial" }, + { + description: "invalid type", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'full' | 'partial', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(PresetTypeSchema).toReject(input, expectedError); + } else { + expect(PresetTypeSchema).toValidate(input); + } + }); + }); + + describe("PresetSchema", () => { + const validPresetMinimal = { + _id: "preset123", + name: "my_preset", + config: {}, + }; + + const validPresetWithConfig = { + _id: "preset123", + name: "my_preset", + config: { punctuation: true }, + }; + + const validPresetWithSettingGroups = { + _id: "preset123", + name: "my_preset", + settingGroups: ["test", "behavior"], + config: {}, + }; + + it.each([ + { description: "valid preset minimal", input: validPresetMinimal }, + { description: "valid preset with config", input: validPresetWithConfig }, + { + description: "valid preset with settingGroups", + input: validPresetWithSettingGroups, + }, + { + description: "invalid - missing name", + input: { _id: "preset123" }, + expectedError: "Required", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(PresetSchema).toReject(input, expectedError); + } else { + expect(PresetSchema).toValidate(input); + } + }); + }); + + describe("EditPresetRequestSchema", () => { + it.each([ + { + description: "valid edit preset request with all required fields", + input: { _id: "preset123", name: "updated_preset" }, + expectedError: undefined, + }, + { + description: "valid edit preset request with config update", + input: { _id: "preset123", name: "updated_preset", config: {} }, + expectedError: undefined, + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(EditPresetRequestSchema).toReject(input, expectedError); + } else { + expect(EditPresetRequestSchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/psas.spec.ts b/packages/schemas/__tests__/psas.spec.ts new file mode 100644 index 000000000000..560fb4bfe421 --- /dev/null +++ b/packages/schemas/__tests__/psas.spec.ts @@ -0,0 +1,56 @@ +import { it, expect, describe } from "vitest"; +import { PSASchema } from "../src/psas"; + +describe("psas schemas", () => { + describe("PSASchema", () => { + it.each([ + { + description: "minimal valid PSA", + input: { + _id: "abc123", + message: "Important announcement", + }, + }, + { + description: "valid PSA with all fields", + input: { + _id: "psa_001", + message: "Server maintenance", + date: 1700000000, + level: 2, + sticky: true, + }, + }, + { + description: "invalid _id with special characters", + input: { + _id: "abc@123", + message: "Test", + }, + expectedError: "Invalid", + }, + { + description: "missing message", + input: { + _id: "abc123", + }, + expectedError: "Required", + }, + { + description: "date is negative", + input: { + _id: "abc123", + message: "Test", + date: -1, + }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(PSASchema).toReject(input, expectedError); + } else { + expect(PSASchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/public.spec.ts b/packages/schemas/__tests__/public.spec.ts new file mode 100644 index 000000000000..e179dd9a3e97 --- /dev/null +++ b/packages/schemas/__tests__/public.spec.ts @@ -0,0 +1,66 @@ +import { it, expect, describe } from "vitest"; +import { SpeedHistogramSchema, TypingStatsSchema } from "../src/public"; + +describe("public schemas", () => { + describe("SpeedHistogramSchema", () => { + it.each([ + { + description: "valid record with numeric string keys and int values", + input: { + "10": 5, + "20": 3, + }, + }, + { + description: "non-integer value fails", + input: { + "10": 1.5, + }, + expectedError: "Expected integer, received float", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(SpeedHistogramSchema).toReject(input, expectedError); + } else { + expect(SpeedHistogramSchema).toValidate(input); + } + }); + }); + + describe("TypingStatsSchema", () => { + it.each([ + { + description: "valid typing stats", + input: { + timeTyping: 100, + testsCompleted: 10, + testsStarted: 12, + }, + }, + { + description: "negative timeTyping fails", + input: { + timeTyping: -1, + testsCompleted: 0, + testsStarted: 0, + }, + expectedError: "Number must be greater than or equal to 0", + }, + { + description: "non-integer testsCompleted fails", + input: { + timeTyping: 0, + testsCompleted: 1.5, + testsStarted: 0, + }, + expectedError: "Expected integer, received float", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(TypingStatsSchema).toReject(input, expectedError); + } else { + expect(TypingStatsSchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/quotes.spec.ts b/packages/schemas/__tests__/quotes.spec.ts new file mode 100644 index 000000000000..6b472b402bbe --- /dev/null +++ b/packages/schemas/__tests__/quotes.spec.ts @@ -0,0 +1,687 @@ +import { it, expect, describe } from "vitest"; +import { + QuoteIdSchema, + ApproveQuoteSchema, + QuoteSchema, + QuoteRatingSchema, + QuoteReportReasonSchema, + QuoteDataQuoteSchema, + QuoteDataSchema, +} from "../src/quotes"; + +describe("quotes schemas", () => { + describe("QuoteIdSchema", () => { + it.each([ + { + description: "valid numeric quote id", + input: 123, + }, + { + description: "valid numeric string quote id", + input: "456", + }, + { + description: "valid zero quote id", + input: 0, + }, + { + description: "valid numeric string zero quote id", + input: "0", + }, + { + description: "negative number", + input: -1, + expectedError: "Number must be greater than or equal to 0", + }, + { + description: "non-numeric string", + input: "abc", + expectedError: "Invalid", + }, + { + description: "float number", + input: 1.5, + expectedError: "Expected integer, received float", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(QuoteIdSchema).toReject(input, expectedError); + } else { + expect(QuoteIdSchema).toValidate(input); + } + }); + }); + + describe("ApproveQuoteSchema", () => { + it.each([ + { + description: "valid approve quote", + input: { + id: 123, + text: "Test quote text", + source: "Test source", + length: 18, + approvedBy: "John Doe", + }, + }, + { + description: "missing id", + input: { + text: "Test quote text", + source: "Test source", + length: 18, + approvedBy: "John Doe", + }, + expectedError: "Invalid input", + }, + { + description: "missing text", + input: { + id: 123, + source: "Test source", + length: 18, + approvedBy: "John Doe", + }, + expectedError: "Required", + }, + { + description: "missing source", + input: { + id: 123, + text: "Test quote text", + length: 18, + approvedBy: "John Doe", + }, + expectedError: "Required", + }, + { + description: "missing length", + input: { + id: 123, + text: "Test quote text", + source: "Test source", + approvedBy: "John Doe", + }, + expectedError: "Required", + }, + { + description: "missing approvedBy", + input: { + id: 123, + text: "Test quote text", + source: "Test source", + length: 18, + }, + expectedError: "Required", + }, + { + description: "length is zero", + input: { + id: 123, + text: "Test quote text", + source: "Test source", + length: 0, + approvedBy: "John Doe", + }, + expectedError: "Number must be greater than 0", + }, + { + description: "length is negative", + input: { + id: 123, + text: "Test quote text", + source: "Test source", + length: -1, + approvedBy: "John Doe", + }, + expectedError: "Number must be greater than 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ApproveQuoteSchema).toReject(input, expectedError); + } else { + expect(ApproveQuoteSchema).toValidate(input); + } + }); + }); + + describe("QuoteSchema", () => { + it.each([ + { + description: "valid quote", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + text: "Test quote text", + source: "Test source", + language: "english", + submittedBy: "60d5f3d9e4b0a71f8d9f1234", + timestamp: 1625097993000, + approved: true, + }, + }, + { + description: "approved is false", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + text: "Test quote text", + source: "Test source", + language: "english", + submittedBy: "60d5f3d9e4b0a71f8d9f1234", + timestamp: 1625097993000, + approved: false, + }, + }, + { + description: "missing _id", + input: { + text: "Test quote text", + source: "Test source", + language: "english", + submittedBy: "60d5f3d9e4b0a71f8d9f1234", + timestamp: 1625097993000, + approved: true, + }, + expectedError: "Required", + }, + { + description: "missing text", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + source: "Test source", + language: "english", + submittedBy: "60d5f3d9e4b0a71f8d9f1234", + timestamp: 1625097993000, + approved: true, + }, + expectedError: "Required", + }, + { + description: "missing source", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + text: "Test quote text", + language: "english", + submittedBy: "60d5f3d9e4b0a71f8d9f1234", + timestamp: 1625097993000, + approved: true, + }, + expectedError: "Required", + }, + { + description: "missing language", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + text: "Test quote text", + source: "Test source", + submittedBy: "60d5f3d9e4b0a71f8d9f1234", + timestamp: 1625097993000, + approved: true, + }, + expectedError: "Required", + }, + { + description: "missing submittedBy", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + text: "Test quote text", + source: "Test source", + language: "english", + timestamp: 1625097993000, + approved: true, + }, + expectedError: "Required", + }, + { + description: "missing timestamp", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + text: "Test quote text", + source: "Test source", + language: "english", + submittedBy: "60d5f3d9e4b0a71f8d9f1234", + approved: true, + }, + expectedError: "Required", + }, + { + description: "missing approved", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + text: "Test quote text", + source: "Test source", + language: "english", + submittedBy: "60d5f3d9e4b0a71f8d9f1234", + timestamp: 1625097993000, + }, + expectedError: "Required", + }, + { + description: "timestamp is negative", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + text: "Test quote text", + source: "Test source", + language: "english", + submittedBy: "60d5f3d9e4b0a71f8d9f1234", + timestamp: -1, + approved: true, + }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(QuoteSchema).toReject(input, expectedError); + } else { + expect(QuoteSchema).toValidate(input); + } + }); + }); + + describe("QuoteRatingSchema", () => { + it.each([ + { + description: "valid quote rating", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + language: "english", + quoteId: 123, + average: 4.5, + ratings: 10, + totalRating: 45, + }, + }, + { + description: "average is zero", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + language: "english", + quoteId: 123, + average: 0, + ratings: 0, + totalRating: 0, + }, + }, + { + description: "missing _id", + input: { + language: "english", + quoteId: 123, + average: 4.5, + ratings: 10, + totalRating: 45, + }, + expectedError: "Required", + }, + { + description: "missing language", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + quoteId: 123, + average: 4.5, + ratings: 10, + totalRating: 45, + }, + expectedError: "Required", + }, + { + description: "missing quoteId", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + language: "english", + average: 4.5, + ratings: 10, + totalRating: 45, + }, + expectedError: "Invalid input", + }, + { + description: "missing average", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + language: "english", + quoteId: 123, + ratings: 10, + totalRating: 45, + }, + expectedError: "Required", + }, + { + description: "missing ratings", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + language: "english", + quoteId: 123, + average: 4.5, + totalRating: 45, + }, + expectedError: "Required", + }, + { + description: "missing totalRating", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + language: "english", + quoteId: 123, + average: 4.5, + ratings: 10, + }, + expectedError: "Required", + }, + { + description: "average is negative", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + language: "english", + quoteId: 123, + average: -0.5, + ratings: 10, + totalRating: 45, + }, + expectedError: "Number must be greater than or equal to 0", + }, + { + description: "ratings is negative", + input: { + _id: "60d5f3d9e4b0a71f8d9f1234", + language: "english", + quoteId: 123, + average: 4.5, + ratings: -1, + totalRating: 45, + }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(QuoteRatingSchema).toReject(input, expectedError); + } else { + expect(QuoteRatingSchema).toValidate(input); + } + }); + }); + + describe("QuoteReportReasonSchema", () => { + it.each([ + { + description: "valid reason - grammatical error", + input: "Grammatical error", + }, + { + description: "valid reason - duplicate quote", + input: "Duplicate quote", + }, + { + description: "valid reason - inappropriate content", + input: "Inappropriate content", + }, + { + description: "valid reason - low quality content", + input: "Low quality content", + }, + { + description: "valid reason - incorrect source", + input: "Incorrect source", + }, + { + description: "invalid reason", + input: "Invalid reason", + expectedError: + "Invalid enum value. Expected 'Grammatical error' | 'Duplicate quote' | 'Inappropriate content' | 'Low quality content' | 'Incorrect source', received 'Invalid reason'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(QuoteReportReasonSchema).toReject(input, expectedError); + } else { + expect(QuoteReportReasonSchema).toValidate(input); + } + }); + }); + + describe("QuoteDataQuoteSchema", () => { + it.each([ + { + description: "valid quote data quote with britishText", + input: { + id: 123, + text: "Test quote text", + britishText: "British spelling", + source: "Test source", + length: 18, + approvedBy: "John Doe", + }, + }, + { + description: + "valid quote data quote without britishText and approvedBy", + input: { + id: 123, + text: "Test quote text", + source: "Test source", + length: 18, + }, + }, + { + description: "missing id", + input: { + text: "Test quote text", + source: "Test source", + length: 18, + }, + expectedError: "Required", + }, + { + description: "missing text", + input: { + id: 123, + source: "Test source", + length: 18, + }, + expectedError: "Required", + }, + { + description: "missing source", + input: { + id: 123, + text: "Test quote text", + length: 18, + }, + expectedError: "Required", + }, + { + description: "missing length", + input: { + id: 123, + text: "Test quote text", + source: "Test source", + }, + expectedError: "Required", + }, + { + description: "extra property not allowed", + input: { + id: 123, + text: "Test quote text", + source: "Test source", + length: 18, + extraProp: "value", + }, + expectedError: "Unrecognized key(s) in object: 'extraProp'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(QuoteDataQuoteSchema).toReject(input, expectedError); + } else { + expect(QuoteDataQuoteSchema).toValidate(input); + } + }); + }); + + describe("QuoteDataSchema", () => { + it.each([ + { + description: "valid quote data", + input: { + language: "english", + groups: [ + [0, 10], + [10, 20], + [20, 30], + [30, 40], + ], + quotes: [ + { + id: 1, + text: "Quote 1", + source: "Source 1", + length: 8, + }, + { + id: 2, + text: "Quote 2", + britishText: "British quote 2", + source: "Source 2", + length: 9, + approvedBy: "John Doe", + }, + ], + }, + }, + { + description: "missing language", + input: { + groups: [ + [0, 10], + [10, 20], + [20, 30], + [30, 40], + ], + quotes: [ + { + id: 1, + text: "Quote 1", + source: "Source 1", + length: 8, + }, + ], + }, + expectedError: "Required", + }, + { + description: "missing groups", + input: { + language: "english", + quotes: [ + { + id: 1, + text: "Quote 1", + source: "Source 1", + length: 8, + }, + ], + }, + expectedError: "Required", + }, + { + description: "missing quotes", + input: { + language: "english", + groups: [ + [0, 10], + [10, 20], + [20, 30], + [30, 40], + ], + }, + expectedError: "Required", + }, + { + description: "groups length is not 4", + input: { + language: "english", + groups: [ + [0, 10], + [10, 20], + ], + quotes: [ + { + id: 1, + text: "Quote 1", + source: "Source 1", + length: 8, + }, + ], + }, + expectedError: "Array must contain exactly 4 element(s)", + }, + { + description: "groups item is not tuple of length 2", + input: { + language: "english", + groups: [ + [0, 10, 20], + [10, 20], + [20, 30], + [30, 40], + ], + quotes: [ + { + id: 1, + text: "Quote 1", + source: "Source 1", + length: 8, + }, + ], + }, + expectedError: "Array must contain at most 2 element(s)", + }, + { + description: "quotes item has extra property not allowed", + input: { + language: "english", + groups: [ + [0, 10], + [10, 20], + [20, 30], + [30, 40], + ], + quotes: [ + { + id: 1, + text: "Quote 1", + source: "Source 1", + length: 8, + extraProp: "value", + }, + ], + }, + expectedError: "Unrecognized key(s) in object: 'extraProp'", + }, + { + description: "extra property not allowed at root level", + input: { + language: "english", + groups: [ + [0, 10], + [10, 20], + [20, 30], + [30, 40], + ], + quotes: [ + { + id: 1, + text: "Quote 1", + source: "Source 1", + length: 8, + }, + ], + }, + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(QuoteDataSchema).toReject(input, expectedError); + } else { + expect(QuoteDataSchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/results.spec.ts b/packages/schemas/__tests__/results.spec.ts new file mode 100644 index 000000000000..79e96cd566fc --- /dev/null +++ b/packages/schemas/__tests__/results.spec.ts @@ -0,0 +1,394 @@ +import { describe, expect, it } from "vitest"; +import { + CharStatsSchema, + ChartDataSchema, + CompletedEventCustomTextSchema, + CompletedEventSchema, + CustomTextSettingsSchema, + IncompleteTestSchema, + KeyStatsSchema, + OldChartDataSchema, + PostResultResponseSchema, + ResultMinifiedSchema, + ResultSchema, + XpBreakdownSchema, +} from "../src/results"; + +describe("results schemas", () => { + describe("IncompleteTestSchema", () => { + it.each([ + { + description: "valid incomplete test", + input: { + acc: 100, + seconds: 30, + }, + }, + { + description: "acc at minimum", + input: { + acc: 50, + seconds: 0, + }, + }, + { + description: "invalid - acc exceeds 100", + input: { acc: 101, seconds: 30 }, + expectedError: "Number must be less than or equal to 100", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(IncompleteTestSchema).toReject(input, expectedError); + } else { + expect(IncompleteTestSchema).toValidate(input); + } + }); + }); + + describe("OldChartDataSchema", () => { + const validChart = { + wpm: [100, 110, 120], + raw: [105, 115, 125], + err: [0, 1, 2], + }; + it.each([ + { + description: "valid chart data", + input: validChart, + }, + { + description: "invalid - negative value in wpm array", + input: { ...validChart, wpm: [-1, 110, 120] }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(OldChartDataSchema).toReject(input, expectedError); + } else { + expect(OldChartDataSchema).toValidate(input); + } + }); + }); + + describe("ChartDataSchema", () => { + const validChart = { + wpm: [100, 110, 120], + burst: [95, 105, 115], + err: [0, 1, 2], + }; + it.each([ + { + description: "valid chart data", + input: validChart, + }, + { + description: "invalid - negative value in burst array", + input: { ...validChart, burst: [95, -105, 115] }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ChartDataSchema).toReject(input, expectedError); + } else { + expect(ChartDataSchema).toValidate(input); + } + }); + }); + + describe("KeyStatsSchema", () => { + it.each([ + { + description: "valid key stats", + input: { + average: 50, + sd: 10, + }, + }, + { + description: "zero values", + input: { + average: 0, + sd: 0, + }, + }, + { + description: "invalid - negative average", + input: { average: -50, sd: 10 }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(KeyStatsSchema).toReject(input, expectedError); + } else { + expect(KeyStatsSchema).toValidate(input); + } + }); + }); + + describe("CompletedEventCustomTextSchema", () => { + it.each([ + { + description: "valid custom text settings", + input: { + textLen: 100, + mode: "repeat", + pipeDelimiter: false, + limit: { + mode: "word", + value: 100, + }, + }, + }, + { + description: "invalid - negative textLen", + input: { + ...({} as any), + textLen: -100, + mode: "repeat", + pipeDelimiter: false, + limit: { mode: "word", value: 100 }, + }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CompletedEventCustomTextSchema).toReject(input, expectedError); + } else { + expect(CompletedEventCustomTextSchema).toValidate(input); + } + }); + }); + + describe("CustomTextSettingsSchema", () => { + const validSettings = { + text: ["hello", "world"], + mode: "repeat", + pipeDelimiter: false, + limit: { + mode: "word", + value: 100, + }, + }; + it.each([ + { + description: "valid custom text settings", + input: validSettings, + }, + { + description: "invalid - empty text array", + input: { ...validSettings, text: [] }, + expectedError: "Array must contain at least 1 element(s)", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CustomTextSettingsSchema).toReject(input, expectedError); + } else { + expect(CustomTextSettingsSchema).toValidate(input); + } + }); + }); + + describe("CharStatsSchema", () => { + it.each([ + { + description: "valid char stats", + input: [100, 5, 2, 3], + }, + ] as const)("$description", ({ input }) => { + expect(CharStatsSchema).toValidate(input); + }); + }); + + describe("ResultSchema", () => { + const validResult = { + wpm: 100, + rawWpm: 110, + charStats: [100, 5, 2, 3], + acc: 98, + mode: "time", + mode2: "15", + timestamp: 1000000000, + testDuration: 30, + consistency: 95, + keyConsistency: 90, + chartData: { wpm: [100], burst: [95], err: [0] }, + uid: "abc123", + _id: "def456", + name: "Test Result", + }; + it.each([ + { + description: "valid result", + input: validResult, + }, + { + description: "invalid - wpm exceeds max", + input: { ...validResult, wpm: 501 }, + expectedError: "Number must be less than or equal to 420", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ResultSchema).toReject(input, expectedError); + } else { + expect(ResultSchema).toValidate(input); + } + }); + }); + + describe("ResultMinifiedSchema", () => { + const validResult = { + wpm: 100, + rawWpm: 110, + charStats: [100, 5, 2, 3], + acc: 98, + mode: "time", + mode2: "15", + timestamp: 1000000000, + testDuration: 30, + consistency: 95, + keyConsistency: 90, + uid: "abc123", + _id: "def456", + }; + it.each([ + { + description: "valid minified result", + input: validResult, + }, + { + description: "invalid - acc below minimum 50", + input: { ...validResult, acc: 49 }, + expectedError: "Number must be greater than or equal to 50", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ResultMinifiedSchema).toReject(input, expectedError); + } else { + expect(ResultMinifiedSchema).toValidate(input); + } + }); + }); + + describe("CompletedEventSchema", () => { + const validEvent = { + wpm: 100, + rawWpm: 110, + charStats: [100, 5, 2, 3], + acc: 98, + mode: "time", + mode2: "15", + timestamp: 1000000000, + testDuration: 30, + restartCount: 1, + incompleteTestSeconds: 5, + afkDuration: 0, + tags: ["abc123"], + bailedOut: false, + blindMode: false, + lazyMode: false, + funbox: ["ascii"], + language: "english", + difficulty: "normal", + numbers: false, + punctuation: false, + consistency: 95, + keyConsistency: 90, + uid: "uid123", + chartData: { wpm: [100], burst: [95], err: [0] }, + charTotal: 150, + hash: "abc123", + keyDuration: [100, 120, 90], + keySpacing: [50, 60, 45], + keyOverlap: 10, + lastKeyToEnd: 200, + startToFirstKey: 500, + wpmConsistency: 95, + stopOnLetter: false, + incompleteTests: [{ acc: 100, seconds: 30 }], + }; + it.each([ + { + description: "valid completed event", + input: validEvent, + }, + { + description: "invalid - wpm exceeds max", + input: { ...validEvent, wpm: 501 }, + expectedError: "Number must be less than or equal to 420", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CompletedEventSchema).toReject(input, expectedError); + } else { + expect(CompletedEventSchema).toValidate(input); + } + }); + }); + + describe("XpBreakdownSchema", () => { + const validBreakdown = { + base: 10, + fullAccuracy: 5, + quote: 2, + corrected: 3, + punctuation: 1, + numbers: 0, + funbox: 0, + streak: 0, + incomplete: 0, + daily: 0, + accPenalty: 0, + configMultiplier: 1, + }; + it.each([ + { + description: "valid xp breakdown", + input: validBreakdown, + }, + ] as const)("$description", ({ input }) => { + expect(XpBreakdownSchema).toValidate(input); + }); + }); + + describe("PostResultResponseSchema", () => { + const validResponse = { + insertedId: "abc123", + isPb: true, + tagPbs: [], + xp: 15, + dailyXpBonus: false, + xpBreakdown: { + base: 10, + fullAccuracy: 5, + quote: 2, + corrected: 3, + punctuation: 1, + numbers: 0, + funbox: 0, + streak: 0, + incomplete: 0, + daily: 0, + accPenalty: 0, + configMultiplier: 1, + }, + streak: 5, + }; + it.each([ + { + description: "valid post result response", + input: validResponse, + }, + { + description: "invalid - xp is negative", + input: { ...validResponse, xp: -1 }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(PostResultResponseSchema).toReject(input, expectedError); + } else { + expect(PostResultResponseSchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/setup.ts b/packages/schemas/__tests__/setup.ts new file mode 100644 index 000000000000..58f780cdee68 --- /dev/null +++ b/packages/schemas/__tests__/setup.ts @@ -0,0 +1,39 @@ +import { expect } from "vitest"; +import { z } from "zod"; + +expect.extend({ + toValidate(schema: z.ZodType, input: unknown) { + const result = schema.safeParse(input); + if (result.success) { + return { pass: true, message: () => "" }; + } + return { + pass: false, + message: () => + `expected input to be valid, got errors: ${JSON.stringify(result.error.issues.map((i) => i.message))}`, + }; + }, + + toReject(schema: z.ZodType, input: unknown, errorMessage?: string) { + const result = schema.safeParse(input); + if (!result.success) { + const errors = result.error.issues.map((i) => i.message); + if (errorMessage !== undefined) { + const match = errors.some((e) => e.includes(errorMessage)); + if (match) { + return { pass: true, message: () => "" }; + } + return { + pass: false, + message: () => + `expected "${errorMessage}" in errors: ${JSON.stringify(errors)}`, + }; + } + return { pass: true, message: () => "" }; + } + return { + pass: false, + message: () => `expected input to be invalid, but it passed validation`, + }; + }, +}); diff --git a/packages/schemas/__tests__/shared.spec.ts b/packages/schemas/__tests__/shared.spec.ts new file mode 100644 index 000000000000..3645826342c5 --- /dev/null +++ b/packages/schemas/__tests__/shared.spec.ts @@ -0,0 +1,287 @@ +import { it, expect, describe } from "vitest"; +import { + DifficultySchema, + PersonalBestSchema, + PersonalBestsSchema, + DefaultWordsModeSchema, + DefaultTimeModeSchema, + QuoteLengthSchema, + ModeSchema, + Mode2Schema, +} from "../src/shared"; + +describe("shared schemas", () => { + describe("DifficultySchema", () => { + it.each([ + { description: "valid normal", input: "normal" }, + { description: "valid expert", input: "expert" }, + { description: "valid master", input: "master" }, + { + description: "invalid difficulty", + input: "invalid", + expectedError: "Invalid enum value", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(DifficultySchema).toReject(input, expectedError); + } else { + expect(DifficultySchema).toValidate(input); + } + }); + }); + + describe("PersonalBestSchema", () => { + it.each([ + { + description: "valid personal best", + input: { + acc: 95, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: 100, + timestamp: 1234567890, + }, + }, + { + description: "acc exceeds 100", + input: { + acc: 101, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: 100, + timestamp: 1234567890, + }, + expectedError: "Number must be less than or equal to 100", + }, + { + description: "wpm is negative", + input: { + acc: 95, + consistency: 90, + difficulty: "normal", + language: "english", + raw: -1, + wpm: 100, + timestamp: 1234567890, + }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(PersonalBestSchema).toReject(input, expectedError); + } else { + expect(PersonalBestSchema).toValidate(input); + } + }); + }); + + describe("PersonalBestsSchema", () => { + it.each([ + { + description: "valid personal bests record", + input: { + time: { + "10": [ + { + acc: 95, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: 100, + timestamp: 1234567890, + }, + ], + }, + words: { + "10": [ + { + acc: 95, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: 100, + timestamp: 1234567890, + }, + ], + }, + quote: { + "1": [ + { + acc: 95, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: 100, + timestamp: 1234567890, + }, + ], + }, + custom: { + custom: [ + { + acc: 95, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: 100, + timestamp: 1234567890, + }, + ], + }, + zen: { + zen: [ + { + acc: 95, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: 100, + timestamp: 1234567890, + }, + ], + }, + }, + }, + { + description: "invalid personal best in record", + input: { + time: { + "10": [ + { + acc: 95, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: -1, + timestamp: 1234567890, + }, + ], + }, + words: {}, + quote: {}, + custom: { custom: [] }, + zen: { zen: [] }, + }, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(PersonalBestsSchema).toReject(input, expectedError); + } else { + expect(PersonalBestsSchema).toValidate(input); + } + }); + }); + + describe("DefaultWordsModeSchema", () => { + it.each([ + { description: "valid 10", input: "10" }, + { description: "valid 25", input: "25" }, + { description: "valid 50", input: "50" }, + { description: "valid 100", input: "100" }, + { + description: "invalid mode", + input: "30", + expectedError: "Invalid input", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(DefaultWordsModeSchema).toReject(input, expectedError); + } else { + expect(DefaultWordsModeSchema).toValidate(input); + } + }); + }); + + describe("DefaultTimeModeSchema", () => { + it.each([ + { description: "valid 15", input: "15" }, + { description: "valid 30", input: "30" }, + { description: "valid 60", input: "60" }, + { description: "valid 120", input: "120" }, + { + description: "invalid mode", + input: "45", + expectedError: "Invalid input", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(DefaultTimeModeSchema).toReject(input, expectedError); + } else { + expect(DefaultTimeModeSchema).toValidate(input); + } + }); + }); + + describe("QuoteLengthSchema", () => { + it.each([ + { description: "valid short", input: "short" }, + { description: "valid medium", input: "medium" }, + { description: "valid long", input: "long" }, + { description: "valid thicc", input: "thicc" }, + { + description: "invalid length", + input: "tiny", + expectedError: "Invalid input", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(QuoteLengthSchema).toReject(input, expectedError); + } else { + expect(QuoteLengthSchema).toValidate(input); + } + }); + }); + + describe("ModeSchema", () => { + it.each([ + { description: "valid mode time", input: "time" }, + { description: "valid mode words", input: "words" }, + { description: "valid mode quote", input: "quote" }, + { description: "valid mode custom", input: "custom" }, + { description: "valid mode zen", input: "zen" }, + { + description: "invalid mode", + input: "invalid", + expectedError: + "Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'invalid'", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ModeSchema).toReject(input, expectedError); + } else { + expect(ModeSchema).toValidate(input); + } + }); + }); + + describe("Mode2Schema", () => { + it.each([ + { description: "valid number string", input: "10" }, + { description: "valid zen", input: "zen" }, + { description: "valid custom", input: "custom" }, + { + description: "invalid value", + input: "invalid", + expectedError: + "Needs to be a number or a number represented as a string", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(Mode2Schema).toReject(input, expectedError); + } else { + expect(Mode2Schema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/themes.spec.ts b/packages/schemas/__tests__/themes.spec.ts new file mode 100644 index 000000000000..dca4753a9cae --- /dev/null +++ b/packages/schemas/__tests__/themes.spec.ts @@ -0,0 +1,23 @@ +import { it, expect, describe } from "vitest"; +import { ThemeNameSchema } from "../src/themes"; + +describe("themes schemas", () => { + describe("ThemeNameSchema", () => { + it.each([ + { description: "valid theme dracula", input: "dracula" }, + { description: "valid theme rose_pine", input: "rose_pine" }, + { description: "valid theme future_funk", input: "future_funk" }, + { + description: "invalid theme", + input: "invalid_theme", + expectedError: "Must be a known theme", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(ThemeNameSchema).toReject(input, expectedError); + } else { + expect(ThemeNameSchema).toValidate(input); + } + }); + }); +}); diff --git a/packages/schemas/__tests__/tsconfig.json b/packages/schemas/__tests__/tsconfig.json index bc5ae47e535d..8f192081b529 100644 --- a/packages/schemas/__tests__/tsconfig.json +++ b/packages/schemas/__tests__/tsconfig.json @@ -3,5 +3,6 @@ "compilerOptions": { "noEmit": true }, + "files": ["vitest.d.ts"], "include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"] } diff --git a/packages/schemas/__tests__/users.spec.ts b/packages/schemas/__tests__/users.spec.ts new file mode 100644 index 000000000000..02a9b502fb12 --- /dev/null +++ b/packages/schemas/__tests__/users.spec.ts @@ -0,0 +1,922 @@ +import { it, expect, describe } from "vitest"; +import { + ResultFilterPresetNameSchema, + ResultFiltersSchema, + StreakHourOffsetSchema, + UserStreakSchema, + TagNameSchema, + UserTagSchema, + TwitterProfileSchema, + GithubProfileSchema, + WebsiteSchema, + UserProfileDetailsSchema, + CustomThemeNameSchema, + CustomThemeSchema, + PremiumInfoSchema, + UserQuoteRatingsSchema, + UserLbMemorySchema, + RankAndCountSchema, + AllTimeLbsSchema, + BadgeSchema, + UserInventorySchema, + QuoteModSchema, + TestActivitySchema, + CountByYearAndDaySchema, + FavoriteQuotesSchema, + UserEmailSchema, + UserNameWithoutFilterSchema, + UserNameSchema, + UserSchema, + TypingStatsSchema, + UserProfileSchema, + RewardTypeSchema, + XpRewardSchema, + BadgeRewardSchema, + AllRewardsSchema, + MonkeyMailSchema, + ReportUserReasonSchema, + PasswordSchema, + FriendSchema, +} from "../src/users"; + +// Constants for complex nested objects +const validUserStreak = { + lastResultTimestamp: 1234567890, + length: 10, + maxLength: 100, +}; + +const validUserTag = { + _id: "tag123", + name: "my_tag", + personalBests: { + time: { "10": [] }, + words: { "10": [] }, + quote: { "1": [] }, + custom: { custom: [] }, + zen: { zen: [] }, + }, +}; + +const validUserProfileDetails = { + bio: "Test user bio", + keyboard: "Mechanical", +}; + +const validCustomTheme = { + _id: "theme123", + name: "my_theme", + colors: [ + "#ffffff", + "#000000", + "#ff0000", + "#00ff00", + "#0000ff", + "#ffff00", + "#ff00ff", + "#00ffff", + "#ffffff", + "#000000", + ], +}; + +const validPremiumInfo = { + startTimestamp: 1234567890, + expirationTimestamp: -1, +}; + +const validUserQuoteRatings = { + english: { "1": 5 }, +}; + +const validUserLbMemory = { + time: { "10": { english: 100 } }, +}; + +const validRankAndCount = { + rank: 1, + count: 100, +}; + +const validAllTimeLbs = { + time: { "10": { english: { count: 100 } } }, +}; + +const validBadge = { + id: 1, +}; + +const validUserInventory = { + badges: [{ id: 1 }], +}; + +const validTestActivity = { + testsByDays: [10, 20, 30], + lastDay: 1234567890, +}; + +const validCountByYearAndDay = { + "2023": [1, 2, 3], +}; + +const validFavoriteQuotes = { + english: ["1", "2"], +}; + +const validUserEmail = "user@example.com"; + +const validUserNameWithoutFilter = "john_doe"; + +const validUserSchema = { + name: "john_doe", + email: "user@example.com", + uid: "uid123", + addedAt: 1234567890, + personalBests: { + time: { + "10": [ + { + acc: 95, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: 100, + timestamp: 1234567890, + }, + ], + }, + words: { + "10": [ + { + acc: 95, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: 100, + timestamp: 1234567890, + }, + ], + }, + quote: { + "1": [ + { + acc: 95, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: 100, + timestamp: 1234567890, + }, + ], + }, + custom: { + custom: [ + { + acc: 95, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: 100, + timestamp: 1234567890, + }, + ], + }, + zen: { + zen: [ + { + acc: 100, + consistency: 90, + difficulty: "normal", + language: "english", + raw: 120, + wpm: 100, + timestamp: 1234567890, + }, + ], + }, + }, + allTimeLbs: { time: { "10": { english: { count: 100 } } } }, +}; + +const validTypingStats = { + completedTests: 10, + startedTests: 15, + timeTyping: 3600, +}; + +const validUserProfile = { + uid: "uid123", + name: "john_doe", + banned: false, + addedAt: 1234567890, + discordId: "discord123", + discordAvatar: "avatar.png", + xp: 1000, + lbOptOut: false, + isPremium: true, + inventory: { badges: [{ id: 1 }] }, + allTimeLbs: { time: { "10": { english: { count: 100 } } } }, + testActivity: { testsByDays: [10], lastDay: 1234567890 }, + typingStats: { completedTests: 10, startedTests: 15, timeTyping: 3600 }, + personalBests: { time: { "10": [] }, words: { "10": [] } }, + streak: 10, + maxStreak: 100, + details: { bio: "Test" }, +}; + +const validXpReward = { + type: "xp", + item: 1, +}; + +const validBadgeReward = { + type: "badge", + item: { id: 1 }, +}; + +const validMonkeyMail = { + id: "mail123", + subject: "Welcome!", + body: "Welcome to Monkeytype!", + timestamp: 1234567890, + read: false, + rewards: [{ type: "xp", item: 1 }], +}; + +const validPassword = "Password123!"; + +describe("users schemas", () => { + describe("ResultFilterPresetNameSchema", () => { + it.each([ + { description: "valid preset name", input: validUserNameWithoutFilter }, + { + description: "exceeds max length", + input: "a".repeat(17), + expectedError: "String must contain at most 16 character", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(ResultFilterPresetNameSchema).toReject(input, expectedError); + } else { + expect(ResultFilterPresetNameSchema).toValidate(input); + } + }); + }); + + describe("ResultFiltersSchema", () => { + const validInput = { + _id: "abc123", + name: "my_preset", + pb: { yes: true, no: false }, + difficulty: { normal: true, expert: false, master: true }, + mode: { time: true, words: true, quote: false, custom: true, zen: false }, + words: { "10": true, "25": false }, + time: { "30": true, "60": false }, + quoteLength: { short: true, medium: false, long: true, thicc: false }, + punctuation: { on: true, off: false }, + numbers: { on: true, off: false }, + date: { + last_day: true, + last_week: false, + last_month: false, + last_3months: false, + all: false, + }, + tags: { abc123: true, none: true }, + language: { english: true, spanish: false }, + funbox: { arrows: true, mirror: false }, + }; + + it.each([ + { description: "valid result filters", input: validInput } as const, + { + description: "missing required field", + input: { _id: "abc123" }, + expectedError: "Required", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(ResultFiltersSchema).toReject(input, expectedError); + } else { + expect(ResultFiltersSchema).toValidate(input); + } + }); + }); + + describe("StreakHourOffsetSchema", () => { + it.each([ + { description: "valid offset 0", input: 0 } as const, + { description: "valid offset 12", input: 12 } as const, + { description: "valid negative offset -11", input: -11 } as const, + { + description: "exceeds max", + input: 13, + expectedError: "Number must be less than or equal to 12", + } as const, + { + description: "below min", + input: -12, + expectedError: "Number must be greater than or equal to -11", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(StreakHourOffsetSchema).toReject(input, expectedError); + } else { + expect(StreakHourOffsetSchema).toValidate(input); + } + }); + }); + + describe("UserStreakSchema", () => { + const validInput = { ...validUserStreak }; + + it.each([ + { description: "valid user streak", input: validInput } as const, + { + description: "invalid - negative length", + input: { ...validInput, length: -1 }, + expectedError: "Number must be greater than or equal to 0", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(UserStreakSchema).toReject(input, expectedError); + } else { + expect(UserStreakSchema).toValidate(input); + } + }); + }); + + describe("TagNameSchema", () => { + it.each([ + { + description: "valid tag name", + input: validUserNameWithoutFilter, + } as const, + { + description: "exceeds max length", + input: "a".repeat(17), + expectedError: "String must contain at most 16 character", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(TagNameSchema).toReject(input, expectedError); + } else { + expect(TagNameSchema).toValidate(input); + } + }); + }); + + describe("UserTagSchema", () => { + const validInput = { ...validUserTag }; + + it.each([ + { description: "valid user tag", input: validInput } as const, + { + description: "invalid - missing name", + input: { + _id: validUserTag._id, + personalBests: validUserTag.personalBests, + }, + expectedError: "Required", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(UserTagSchema).toReject(input, expectedError); + } else { + expect(UserTagSchema).toValidate(input); + } + }); + }); + + describe("TwitterProfileSchema", () => { + it.each([ + { + description: "valid twitter profile", + input: validUserNameWithoutFilter, + } as const, + { + description: "empty string is valid", + input: "", + } as const, + { + description: "exceeds max length (15)", + input: "a".repeat(16), + expectedError: "String must contain at most 15 character", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(TwitterProfileSchema).toReject(input, expectedError); + } else { + expect(TwitterProfileSchema).toValidate(input); + } + }); + }); + + describe("GithubProfileSchema", () => { + it.each([ + { + description: "valid github profile", + input: validUserNameWithoutFilter, + } as const, + { + description: "empty string is valid", + input: "", + } as const, + { + description: "exceeds max length (39)", + input: "a".repeat(40), + expectedError: "String must contain at most 39 character", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(GithubProfileSchema).toReject(input, expectedError); + } else { + expect(GithubProfileSchema).toValidate(input); + } + }); + }); + + describe("WebsiteSchema", () => { + it.each([ + { description: "valid website", input: "https://example.com" } as const, + { + description: "empty string is valid", + input: "", + } as const, + { + description: "exceeds max length (200)", + input: "a".repeat(201), + expectedError: "String must contain at most 200 character", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(WebsiteSchema).toReject(input, expectedError); + } else { + expect(WebsiteSchema).toValidate(input); + } + }); + }); + + describe("UserProfileDetailsSchema", () => { + const validInput = { ...validUserProfileDetails }; + + it.each([ + { description: "valid user profile details", input: validInput } as const, + { + description: "with socialProfiles", + input: { + ...validInput, + socialProfiles: { twitter: validUserNameWithoutFilter }, + }, + } as const, + { + description: "bio exceeds max length", + input: { ...validInput, bio: "a".repeat(251) }, + expectedError: "String must contain at most 250 character", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(UserProfileDetailsSchema).toReject(input, expectedError); + } else { + expect(UserProfileDetailsSchema).toValidate(input); + } + }); + }); + + describe("CustomThemeNameSchema", () => { + it.each([ + { + description: "valid custom theme name", + input: validUserNameWithoutFilter, + } as const, + { + description: "exceeds max length", + input: "a".repeat(17), + expectedError: "String must contain at most 16 character", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(CustomThemeNameSchema).toReject(input, expectedError); + } else { + expect(CustomThemeNameSchema).toValidate(input); + } + }); + }); + + describe("CustomThemeSchema", () => { + const validInput = { ...validCustomTheme }; + + it.each([ + { description: "valid custom theme", input: validInput } as const, + { + description: "invalid - missing _id", + input: { name: validCustomTheme.name, colors: validCustomTheme.colors }, + expectedError: "Required", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(CustomThemeSchema).toReject(input, expectedError); + } else { + expect(CustomThemeSchema).toValidate(input); + } + }); + }); + + describe("PremiumInfoSchema", () => { + it.each([ + { + description: "valid premium info with expiration", + input: validPremiumInfo, + } as const, + { + description: "valid lifetime premium", + input: { startTimestamp: 1234567890, expirationTimestamp: -1 }, + } as const, + { + description: "invalid - negative startTimestamp", + input: { ...validPremiumInfo, startTimestamp: -1 }, + expectedError: "Number must be greater than or equal to 0", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(PremiumInfoSchema).toReject(input, expectedError); + } else { + expect(PremiumInfoSchema).toValidate(input); + } + }); + }); + + describe("UserQuoteRatingsSchema", () => { + it.each([ + { + description: "valid user quote ratings", + input: validUserQuoteRatings, + } as const, + { + description: "invalid - negative rating", + input: { english: { "1": -1 } }, + expectedError: "Number must be greater than or equal to 0", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(UserQuoteRatingsSchema).toReject(input, expectedError); + } else { + expect(UserQuoteRatingsSchema).toValidate(input); + } + }); + }); + + describe("UserLbMemorySchema", () => { + it.each([ + { + description: "valid user lb memory", + input: validUserLbMemory, + } as const, + { + description: "invalid - invalid value", + input: { time: { "10": { english: "not-a-number" } } }, + expectedError: "Expected number, received string", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(UserLbMemorySchema).toReject(input, expectedError); + } else { + expect(UserLbMemorySchema).toValidate(input); + } + }); + }); + + describe("RankAndCountSchema", () => { + it.each([ + { description: "valid rank and count", input: validRankAndCount }, + { + description: "with optional rank", + input: { count: 100 }, + }, + ] as const)("$description", ({ input }) => { + expect(RankAndCountSchema).toValidate(input); + }); + + it("invalid - negative rank", () => { + expect(RankAndCountSchema).toReject( + { ...validRankAndCount, rank: -1 }, + "Number must be greater than or equal to 0", + ); + }); + }); + + describe("AllTimeLbsSchema", () => { + it.each([ + { description: "valid all time lbs", input: validAllTimeLbs } as const, + ])("$description", ({ input }) => { + expect(AllTimeLbsSchema).toValidate(input); + }); + }); + + describe("BadgeSchema", () => { + it.each([ + { description: "valid badge", input: validBadge } as const, + { + description: "with optional selected", + input: { ...validBadge, selected: true }, + } as const, + ])("$description", ({ input }) => { + expect(BadgeSchema).toValidate(input); + }); + }); + + describe("UserInventorySchema", () => { + it.each([ + { + description: "valid user inventory", + input: validUserInventory, + } as const, + ])("$description", ({ input }) => { + expect(UserInventorySchema).toValidate(input); + }); + }); + + describe("QuoteModSchema", () => { + it.each([ + { description: "valid admin for all languages", input: true } as const, + { + description: "valid admin for specific language", + input: "english", + } as const, + ])("$description", ({ input }) => { + expect(QuoteModSchema).toValidate(input); + }); + }); + + describe("TestActivitySchema", () => { + const validInput = { ...validTestActivity }; + + it.each([ + { description: "valid test activity", input: validInput } as const, + { + description: "with null values", + input: { ...validInput, testsByDays: [10, null, 30] }, + } as const, + ])("$description", ({ input }) => { + expect(TestActivitySchema).toValidate(input); + }); + }); + + describe("CountByYearAndDaySchema", () => { + it.each([ + { + description: "valid count by year and day", + input: validCountByYearAndDay, + } as const, + ])("$description", ({ input }) => { + expect(CountByYearAndDaySchema).toValidate(input); + }); + }); + + describe("FavoriteQuotesSchema", () => { + it.each([ + { + description: "valid favorite quotes", + input: validFavoriteQuotes, + } as const, + ])("$description", ({ input }) => { + expect(FavoriteQuotesSchema).toValidate(input); + }); + }); + + describe("UserEmailSchema", () => { + it.each([ + { description: "valid email", input: validUserEmail } as const, + { + description: "invalid email format", + input: "not-an-email", + expectedError: "Invalid email", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(UserEmailSchema).toReject(input, expectedError); + } else { + expect(UserEmailSchema).toValidate(input); + } + }); + }); + + describe("UserNameWithoutFilterSchema", () => { + it.each([ + { + description: "valid username without filter", + input: validUserNameWithoutFilter, + } as const, + { + description: "exceeds max length", + input: "a".repeat(17), + expectedError: "String must contain at most 16 character", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(UserNameWithoutFilterSchema).toReject(input, expectedError); + } else { + expect(UserNameWithoutFilterSchema).toValidate(input); + } + }); + }); + + describe("UserNameSchema", () => { + it.each([ + { + description: "valid username with filter", + input: validUserNameWithoutFilter, + } as const, + ])("$description", ({ input }) => { + expect(UserNameSchema).toValidate(input); + }); + }); + + describe("UserSchema", () => { + const validInput = { ...validUserSchema }; + + it.each([ + { + description: "valid user with all required fields", + input: validInput, + } as const, + { + description: "with optional fields", + input: { ...validInput, xp: 1000, banned: false }, + } as const, + ])("$description", ({ input }) => { + expect(UserSchema).toValidate(input); + }); + + it("invalid - missing required field", () => { + expect(UserSchema).toReject({ name: "test" }, "Required"); + }); + }); + + describe("TypingStatsSchema", () => { + const validInput = { ...validTypingStats }; + + it.each([ + { description: "valid typing stats", input: validInput } as const, + { + description: "with optional fields", + input: { timeTyping: 3600 }, + } as const, + ])("$description", ({ input }) => { + expect(TypingStatsSchema).toValidate(input); + }); + }); + + describe("UserProfileSchema", () => { + const validInput = { ...validUserProfile }; + + it.each([ + { description: "valid user profile", input: validInput } as const, + ])("$description", ({ input }) => { + expect(UserProfileSchema).toValidate(input); + }); + }); + + describe("RewardTypeSchema", () => { + it.each([ + { description: "valid reward type xp", input: "xp" } as const, + { + description: "invalid reward type", + input: "invalid", + expectedError: "Invalid enum value", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(RewardTypeSchema).toReject(input, expectedError); + } else { + expect(RewardTypeSchema).toValidate(input); + } + }); + }); + + describe("XpRewardSchema", () => { + const validInput = { ...validXpReward }; + + it.each([ + { description: "valid xp reward", input: validInput } as const, + { + description: "invalid - invalid item", + input: { ...validXpReward, item: "not-a-number" }, + expectedError: "Expected number, received string", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(XpRewardSchema).toReject(input, expectedError); + } else { + expect(XpRewardSchema).toValidate(input); + } + }); + }); + + describe("BadgeRewardSchema", () => { + const validInput = { ...validBadgeReward }; + + it.each([ + { description: "valid badge reward", input: validInput } as const, + ])("$description", ({ input }) => { + expect(BadgeRewardSchema).toValidate(input); + }); + }); + + describe("AllRewardsSchema", () => { + it.each([ + { description: "valid all rewards (xp)", input: validXpReward } as const, + { + description: "valid all rewards (badge)", + input: validBadgeReward, + } as const, + ])("$description", ({ input }) => { + expect(AllRewardsSchema).toValidate(input); + }); + }); + + describe("MonkeyMailSchema", () => { + const validInput = { ...validMonkeyMail }; + + it.each([ + { description: "valid monkey mail", input: validInput } as const, + { + description: "with read true", + input: { ...validInput, read: true }, + } as const, + ])("$description", ({ input }) => { + expect(MonkeyMailSchema).toValidate(input); + }); + }); + + describe("ReportUserReasonSchema", () => { + it.each([ + { + description: "valid reason inappropriate name", + input: "Inappropriate name", + } as const, + { + description: "invalid reason", + input: "invalid_reason", + expectedError: "Invalid enum value", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(ReportUserReasonSchema).toReject(input, expectedError); + } else { + expect(ReportUserReasonSchema).toValidate(input); + } + }); + }); + + describe("PasswordSchema", () => { + it.each([ + { description: "valid password", input: validPassword } as const, + { + description: "too short", + input: "Pass1!", + expectedError: "must be at least 8 characters", + } as const, + { + description: "no uppercase letter", + input: "password123!", + expectedError: "must contain at least one capital letter", + } as const, + ])("$description", ({ input, expectedError }) => { + if (expectedError !== undefined) { + expect(PasswordSchema).toReject(input, expectedError); + } else { + expect(PasswordSchema).toValidate(input); + } + }); + }); + + describe("FriendSchema", () => { + const validInput = { + uid: "friend123", + name: "friend", + discordId: "discord123", + discordAvatar: "avatar.png", + startedTests: 10, + completedTests: 5, + timeTyping: 3600, + xp: 1000, + banned: false, + lbOptOut: false, + }; + + it.each([ + { description: "valid friend", input: validInput } as const, + { + description: "with optional connectionId", + input: { ...validInput, connectionId: "conn123" }, + } as const, + ])("$description", ({ input }) => { + expect(FriendSchema).toValidate(input); + }); + }); +}); diff --git a/packages/schemas/__tests__/util.spec.ts b/packages/schemas/__tests__/util.spec.ts index 8f29eb0496b9..a91cab831f66 100644 --- a/packages/schemas/__tests__/util.spec.ts +++ b/packages/schemas/__tests__/util.spec.ts @@ -1,56 +1,266 @@ -import { describe, it, expect } from "vitest"; -import { nameWithSeparators, slug } from "../src/util"; +import { it, expect, describe } from "vitest"; +import { + StringNumberSchema, + token, + slug, + nameWithSeparators, + IdSchema, + TagSchema, + NullableStringSchema, + PercentageSchema, + WpmSchema, + CustomTextModeSchema, + CustomTextLimitModeSchema, + PageNumberSchema, +} from "../src/util"; -describe("Schema Validation Tests", () => { - describe("nameWithSeparators", () => { - const schema = nameWithSeparators(); +describe("util schemas", () => { + describe("StringNumberSchema", () => { + it.each([ + { description: "valid number string", input: "10" }, + { description: "valid large number string", input: "123456" }, + { description: "valid number input", input: 10 }, + { + description: "invalid string with letters", + input: "abc123", + expectedError: + "Needs to be a number or a number represented as a string", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(StringNumberSchema).toReject(input, expectedError); + } else { + expect(StringNumberSchema).toValidate(input); + } + }); + }); + + describe("token function", () => { + const TokenSchema = token(); + it.each([ + { description: "valid token", input: "my_token_123" }, + { + description: "invalid token with hyphen", + input: "my-token", + expectedError: "Invalid", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(TokenSchema).toReject(input, expectedError); + } else { + expect(TokenSchema).toValidate(input); + } + }); + }); + + describe("slug function", () => { + const SlugSchema = slug(); + it.each([ + { description: "valid slug", input: "my-slug" }, + { + description: "valid slug with dots and underscores", + input: "my.slug_name", + }, + { + description: "invalid slug starts with dot", + input: ".hidden-slug", + expectedError: "Cannot start with a dot", + }, + { + description: "invalid slug with special char", + input: "my@slug", + expectedError: + "Only letters, numbers, underscores, dots and hyphens allowed", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(SlugSchema).toReject(input, expectedError); + } else { + expect(SlugSchema).toValidate(input); + } + }); + }); + + describe("nameWithSeparators function", () => { + const NameWithSeparatorsSchema = nameWithSeparators(); + it.each([ + { description: "valid name", input: "my-name" }, + { description: "valid name with underscores", input: "my_name" }, + { + description: "invalid name starts with separator", + input: "-my-name", + expectedError: "Separators cannot be at the start or end", + }, + { + description: "invalid name with double separator", + input: "my--name", + expectedError: "Separators cannot be at the start or end", + }, + { + description: "invalid name with special char", + input: "my@name", + expectedError: "Only letters, numbers, underscores and hyphens allowed", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(NameWithSeparatorsSchema).toReject(input, expectedError); + } else { + expect(NameWithSeparatorsSchema).toValidate(input); + } + }); + }); - it("accepts valid names", () => { - expect(schema.safeParse("valid_name").success).toBe(true); - expect(schema.safeParse("valid-name").success).toBe(true); - expect(schema.safeParse("valid123").success).toBe(true); - expect(schema.safeParse("Valid_Name-Check").success).toBe(true); + describe("IdSchema", () => { + it.each([ + { description: "valid id", input: "test_id_123" }, + { + description: "invalid id with hyphen", + input: "test-id", + expectedError: "Invalid", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(IdSchema).toReject(input, expectedError); + } else { + expect(IdSchema).toValidate(input); + } }); + }); - it("rejects leading/trailing separators", () => { - expect(schema.safeParse("_invalid").success).toBe(false); - expect(schema.safeParse("invalid-").success).toBe(false); + describe("TagSchema", () => { + it.each([ + { description: "valid tag under max length", input: "testtag" }, + { description: "tag at max length (50 chars)", input: "a".repeat(50) }, + { + description: "tag exceeds max length", + input: "a".repeat(51), + expectedError: "String must contain at most 50 character(s)", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(TagSchema).toReject(input, expectedError); + } else { + expect(TagSchema).toValidate(input); + } }); + }); - it("rejects consecutive separators", () => { - expect(schema.safeParse("inv__alid").success).toBe(false); - expect(schema.safeParse("inv--alid").success).toBe(false); - expect(schema.safeParse("inv-_alid").success).toBe(false); + describe("NullableStringSchema", () => { + it.each([ + { description: "valid string", input: "test" }, + { description: "valid null", input: null }, + { description: "valid undefined", input: undefined }, + ] as const)("$description", ({ input }) => { + expect(NullableStringSchema).toValidate(input); }); + }); - it("rejects dots", () => { - expect(schema.safeParse("invalid.dot").success).toBe(false); - expect(schema.safeParse(".invalid").success).toBe(false); + describe("PercentageSchema", () => { + it.each([ + { description: "valid percentage", input: 50 }, + { description: "valid 0%", input: 0 }, + { description: "valid 100%", input: 100 }, + { + description: "percentage exceeds 100", + input: 150, + expectedError: "Number must be less than or equal to 100", + }, + { + description: "negative percentage", + input: -1, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(PercentageSchema).toReject(input, expectedError); + } else { + expect(PercentageSchema).toValidate(input); + } }); }); - describe("slug", () => { - const schema = slug(); + describe("WpmSchema", () => { + it.each([ + { description: "valid wpm", input: 100 }, + { description: "valid 0 wpm", input: 0 }, + { description: "valid max wpm (420)", input: 420 }, + { + description: "wpm exceeds max", + input: 500, + expectedError: "Number must be less than or equal to 420", + }, + { + description: "negative wpm", + input: -1, + expectedError: "Number must be greater than or equal to 0", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(WpmSchema).toReject(input, expectedError); + } else { + expect(WpmSchema).toValidate(input); + } + }); + }); - it("accepts valid slugs", () => { - expect(schema.safeParse("valid-slug.123_test").success).toBe(true); - expect(schema.safeParse("valid.dots").success).toBe(true); - expect(schema.safeParse("_leading_underscore_is_fine").success).toBe( - true, - ); - expect(schema.safeParse("-leading_hyphen_is_fine").success).toBe(true); - expect(schema.safeParse("trailing_is_fine_in_slug_").success).toBe(true); + describe("CustomTextModeSchema", () => { + it.each([ + { description: "valid repeat", input: "repeat" }, + { description: "valid random", input: "random" }, + { description: "valid shuffle", input: "shuffle" }, + { + description: "invalid mode", + input: "invalid", + expectedError: "Invalid enum value", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CustomTextModeSchema).toReject(input, expectedError); + } else { + expect(CustomTextModeSchema).toValidate(input); + } }); + }); - it("rejects leading dots", () => { - expect(schema.safeParse(".invalid").success).toBe(false); + describe("CustomTextLimitModeSchema", () => { + it.each([ + { description: "valid word", input: "word" }, + { description: "valid time", input: "time" }, + { description: "valid section", input: "section" }, + { + description: "invalid mode", + input: "invalid", + expectedError: "Invalid enum value", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(CustomTextLimitModeSchema).toReject(input, expectedError); + } else { + expect(CustomTextLimitModeSchema).toValidate(input); + } }); + }); - it("rejects invalid characters", () => { - expect(schema.safeParse("invalid,comma").success).toBe(false); - expect(schema.safeParse(",invalid").success).toBe(false); - expect(schema.safeParse("invalid space").success).toBe(false); - expect(schema.safeParse("invalid#hash").success).toBe(false); + describe("PageNumberSchema", () => { + it.each([ + { description: "valid page number", input: 0 }, + { description: "valid positive page", input: 5 }, + { + description: "invalid negative page", + input: -1, + expectedError: "Number must be greater than or equal to 0", + }, + { + description: "invalid non-integer", + input: 1.5, + expectedError: "Expected integer, received float", + }, + ] as const)("$description", ({ input, expectedError }) => { + if (expectedError) { + expect(PageNumberSchema).toReject(input, expectedError); + } else { + expect(PageNumberSchema).toValidate(input); + } }); }); }); diff --git a/packages/schemas/__tests__/vitest.d.ts b/packages/schemas/__tests__/vitest.d.ts new file mode 100644 index 000000000000..78004f5389bd --- /dev/null +++ b/packages/schemas/__tests__/vitest.d.ts @@ -0,0 +1,12 @@ +// oxlint-disable typescript/consistent-type-definitions +import type { Assertion, AsymmetricMatchersContaining } from "vitest"; + +interface SchemaMachers { + toValidate(input: unknown): void; + toReject(input: unknown, errorMessage?: string): void; +} + +declare module "vitest" { + // oxlint-disable-next-line typescript/no-empty-object-type + interface Assertion extends SchemaMachers {} +} diff --git a/packages/schemas/vitest.config.ts b/packages/schemas/vitest.config.ts index 6e75403ba84c..d2b88fdf1de5 100644 --- a/packages/schemas/vitest.config.ts +++ b/packages/schemas/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", + setupFiles: ["./__tests__/setup.ts"], coverage: { include: ["**/*.ts"], },