From 4707e06de174258a32d1c4d78a30579a34076fdc Mon Sep 17 00:00:00 2001 From: Hank McCord Date: Tue, 1 Oct 2024 13:29:02 -0400 Subject: [PATCH 1/3] Migrate obsoleted jest methods --- .../ajax-handlers/spookyShuffle.spec.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/scripts/modules/ajax-handlers/spookyShuffle.spec.ts b/tests/scripts/modules/ajax-handlers/spookyShuffle.spec.ts index 301774ee..0ce3edff 100644 --- a/tests/scripts/modules/ajax-handlers/spookyShuffle.spec.ts +++ b/tests/scripts/modules/ajax-handlers/spookyShuffle.spec.ts @@ -39,7 +39,7 @@ describe("SpookyShuffleAjaxHandler", () => { const response = {user_id: 4} as unknown as HgResponse; await handler.execute(response); - expect(logger.warn).toBeCalledWith('Unexpected spooky shuffle response.', response); + expect(logger.warn).toHaveBeenCalledWith('Unexpected spooky shuffle response object.', expect.anything()); expect(submitConvertibleCallback).toHaveBeenCalledTimes(0); }); @@ -54,7 +54,7 @@ describe("SpookyShuffleAjaxHandler", () => { }; await handler.execute({memory_game: result} as unknown as HgResponse); - expect(logger.debug).toBeCalledWith('Spooky Shuffle board is not complete yet.'); + expect(logger.debug).toHaveBeenCalledWith('Spooky Shuffle board is not complete yet.'); expect(submitConvertibleCallback).toHaveBeenCalledTimes(0); }); @@ -69,7 +69,7 @@ describe("SpookyShuffleAjaxHandler", () => { }; await handler.execute({memory_game: result} as unknown as HgResponse); - expect(logger.debug).toBeCalledWith('Spooky Shuffle board is not complete yet.'); + expect(logger.debug).toHaveBeenCalledWith('Spooky Shuffle board is not complete yet.'); expect(submitConvertibleCallback).toHaveBeenCalledTimes(0); }); @@ -118,9 +118,9 @@ describe("SpookyShuffleAjaxHandler", () => { }, ]; - expect(submitConvertibleCallback).toBeCalledWith( - expectedConvertible, - expectedItems + expect(submitConvertibleCallback).toHaveBeenCalledWith( + expect.objectContaining(expectedConvertible), + expect.objectContaining(expectedItems) ); }); @@ -183,9 +183,9 @@ describe("SpookyShuffleAjaxHandler", () => { }, ]; - expect(submitConvertibleCallback).toBeCalledWith( - expectedConvertible, - expectedItems + expect(submitConvertibleCallback).toHaveBeenCalledWith( + expect.objectContaining(expectedConvertible), + expect.objectContaining(expectedItems) ); }); @@ -221,8 +221,8 @@ describe("SpookyShuffleAjaxHandler", () => { }; await handler.execute({memory_game: result} as unknown as HgResponse); - expect(logger.warn).toBeCalledWith(`Item 'Test Item' wasn't found in item map. Check its classification type`); - expect(submitConvertibleCallback).not.toBeCalled(); + expect(logger.warn).toHaveBeenCalledWith(`Item 'Test Item' wasn't found in item map. Check its classification type`); + expect(submitConvertibleCallback).not.toHaveBeenCalled(); }); }); From 08e9fe1f35b4760d2f24a75831063d8aa0dc3862 Mon Sep 17 00:00:00 2001 From: Hank McCord Date: Tue, 1 Oct 2024 13:49:48 -0400 Subject: [PATCH 2/3] Add builder pattern helper classes --- tests/utility/builders.ts | 230 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 tests/utility/builders.ts diff --git a/tests/utility/builders.ts b/tests/utility/builders.ts new file mode 100644 index 00000000..fd0e4b78 --- /dev/null +++ b/tests/utility/builders.ts @@ -0,0 +1,230 @@ +import {HgResponse, JournalMarkup, Quests, User} from "@scripts/types/hg"; +import {IntakeMessage} from "@scripts/types/mhct"; + +/* +* Builder pattern classes to help build test data with ease +*/ + +export class HgResponseBuilder { + + activeTurn?: boolean; + user?: User; + page?: unknown; + journalMarkup?: JournalMarkup[]; + + withActiveTurn(active: boolean) { + this.activeTurn = active; + return this; + } + + withUser(user: User) { + this.user = user; + return this; + } + + withPage(page: unknown) { + this.page = page; + return this; + } + + withJournalMarkup(journalMarkup: JournalMarkup[]) { + this.journalMarkup = journalMarkup; + return this; + } + + public build(): HgResponse { + if (this.user == null) { + this.user = new UserBuilder().build(); + } + + return { + success: 1, + active_turn: this.activeTurn, + user: this.user, + page: this.page, + journal_markup: this.journalMarkup, + }; + } +} + +type UserIdentification = Pick; +type UserTurn = Pick +type UserEnvironment = Pick; +type UserTrapStats = Pick; +type UserWeapon = Pick; +type UserBase = Pick; +type UserBait = Pick; +type UserTrinket = Pick; + +export class UserBuilder { + identification: UserIdentification = { + user_id: 1, + sn_user_id: '2', + unique_hash: 'hashbrowns', + has_shield: true, + }; + + turn: UserTurn = { + num_active_turns: 0, + next_activeturn_seconds: 0, + }; + + environment: UserEnvironment = { + environment_id: 9999, + environment_name: 'Test Environment', + }; + + trap: UserTrapStats = { + trap_power: 9001, + trap_luck: 42, + trap_attraction_bonus: 0.05, + trap_power_bonus: 0.01, + }; + + weapon: UserWeapon = { + weapon_name: 'TestWeapon Trap', + weapon_item_id: 1111, + }; + + base: UserBase = { + base_name: 'TestBase Base', + base_item_id: 2222, + }; + + bait: UserBait = { + bait_name: 'TestBait Cheese', + bait_item_id: 3333, + }; + + trinket: UserTrinket = { + trinket_name: 'TestTrinket Charm', + trinket_item_id: 4444, + }; + + quests = { + + }; + + public withIdentification(id: UserIdentification) { + this.identification = id; + return this; + } + + public withTurn(turn: UserTurn) { + this.turn = turn; + return this; + } + + public withEnvironment(environment: UserEnvironment) { + this.environment = environment; + return this; + } + + public withTrapStats(trap: UserTrapStats) { + this.trap = trap; + return this; + } + + public withWeapon(weapon: UserWeapon) { + this.weapon = weapon; + return this; + } + + public withBase(base: UserBase) { + this.base = base; + return this; + } + + public withBait(bait: UserBait) { + this.bait = bait; + return this; + } + + public withTrinket(trinket: UserTrinket) { + this.trinket = trinket; + return this; + } + + public withQuests(quests: Quests) { + this.quests = quests; + return this; + } + + public build(): User { + return { + ...this.identification, + ...this.turn, + ...this.environment, + ...this.trap, + ...this.weapon, + ...this.base, + ...this.bait, + ...this.trinket, + quests: this.quests, + viewing_atts: {}, + }; + } +} + +export class IntakeMessageBuilder { + + stage: unknown; + + withStage(stage: unknown) { + this.stage = stage; + return this; + } + + public build(response: HgResponse): IntakeMessage { + if (response.journal_markup == null) { + throw new Error('Journal Markup cannot be empty'); + } + + const renderData = response.journal_markup[0].render_data; + + const message = { + uuid: '1', + extension_version: '0', + hunter_id_hash: '01020304', + entry_timestamp: renderData.entry_timestamp.toString(), + location: { + id: `${response.user.environment_id}`, + name: `${response.user.environment_name}`, + }, + shield: `${response.user.has_shield}`, + total_power: `${response.user.trap_power}`, + total_luck: `${response.user.trap_luck}`, + attraction_bonus: `${response.user.trap_attraction_bonus * 100}`, + trap: { + id: `${response.user.weapon_item_id}`, + name: `${response.user.weapon_name.replace(/ trap$/i, '')}`, + }, + base: { + id: `${response.user.base_item_id}`, + name: `${response.user.base_name.replace(/ base$/i, '')}`, + }, + cheese: { + id: `${response.user.bait_item_id}`, + name: `${response.user.bait_name.replace(/ cheese$/i, '')}`, + }, + charm: { + id: `${response.user.trinket_item_id}`, + name: `${response.user.trinket_name?.replace(/ charm$/i, '')}`, + }, + mouse: `${renderData.text.replace(/ mouse$/i, '')}`, + // set these to static for now. May want to configure in future + entry_id: '1', + caught: '1', + attracted: '1', + hunt_details: { + hunt_count: '1', + }, + } as unknown as IntakeMessage; + + if (this.stage) { + message.stage = this.stage; + } + + return message; + } +} From 488624c5a3accc7b5a36814724e1fdead30ff7be Mon Sep 17 00:00:00 2001 From: Hank McCord Date: Thu, 17 Oct 2024 14:30:57 -0400 Subject: [PATCH 3/3] Use Zod to validate Spooky Shuffle responses --- .../modules/ajax-handlers/spookyShuffle.ts | 15 ++-- .../ajax-handlers/spookyShuffle.types.ts | 87 ++++++++++--------- .../ajax-handlers/spookyShuffle.spec.ts | 51 ++++++++--- 3 files changed, 96 insertions(+), 57 deletions(-) diff --git a/src/scripts/modules/ajax-handlers/spookyShuffle.ts b/src/scripts/modules/ajax-handlers/spookyShuffle.ts index f85fa3ca..f1fdb8b9 100644 --- a/src/scripts/modules/ajax-handlers/spookyShuffle.ts +++ b/src/scripts/modules/ajax-handlers/spookyShuffle.ts @@ -1,7 +1,7 @@ import {AjaxSuccessHandler} from "./ajaxSuccessHandler"; import {HgItem} from "@scripts/types/mhct"; import {LoggerService} from "@scripts/util/logger"; -import {SpookyShuffleResponse, TitleRange} from "./spookyShuffle.types"; +import {SpookyShuffleResponse, spookyShuffleResponseSchema, TitleRange} from "./spookyShuffle.types"; import {CustomConvertibleIds} from "@scripts/util/constants"; import {parseHgInt} from "@scripts/util/number"; import * as hgFuncs from "@scripts/util/hgFunctions"; @@ -30,7 +30,6 @@ export class SpookyShuffleAjaxHandler extends AjaxSuccessHandler { async execute(responseJSON: unknown): Promise { if (!this.isSpookyShuffleResponse(responseJSON)) { - this.logger.warn("Unexpected spooky shuffle response.", responseJSON); return; } @@ -129,10 +128,14 @@ export class SpookyShuffleAjaxHandler extends AjaxSuccessHandler { * @returns */ private isSpookyShuffleResponse(responseJSON: unknown): responseJSON is SpookyShuffleResponse { - const resultKey: keyof SpookyShuffleResponse = 'memory_game'; - return responseJSON != null && - typeof responseJSON === 'object' && - resultKey in responseJSON; + const response = spookyShuffleResponseSchema.safeParse(responseJSON); + + if (!response.success) { + const errorMessage = response.error.message; + this.logger.warn("Unexpected spooky shuffle response object.", errorMessage); + } + + return response.success; } static ShuffleConvertibleIds: Record = { diff --git a/src/scripts/modules/ajax-handlers/spookyShuffle.types.ts b/src/scripts/modules/ajax-handlers/spookyShuffle.types.ts index f89db0a0..b3d54691 100644 --- a/src/scripts/modules/ajax-handlers/spookyShuffle.types.ts +++ b/src/scripts/modules/ajax-handlers/spookyShuffle.types.ts @@ -1,22 +1,5 @@ -import {HgResponse} from "@scripts/types/hg"; - -export interface SpookyShuffleResponse extends HgResponse { - memory_game: SpookyShuffleStatus; -} - -export interface SpookyShuffleStatus { - is_complete: true | null; - is_upgraded: true | null; - has_selected_testing_pair: boolean; // true on selection of the 2nd card - reward_tiers: RewardTier[]; - title_range: TitleRange; - cards: Card[]; -} - -interface RewardTier { - type: TitleRange; - name: string; // A readable/nice english string of the title range -} +import z from "zod"; +import {hgResponseSchema} from "@scripts/types/hg"; const TitleRanges = [ 'novice_journeyman', @@ -25,23 +8,49 @@ const TitleRanges = [ 'grand_duke_plus', ] as const; -export type TitleRange = typeof TitleRanges[number]; - -type Card = { - id: number; - quantity: number | null; - is_matched: true | null; - is_tested_pair: true | null; -} & (KnownCard | UnknownCard); - -interface KnownCard { - name: string; - is_revealed: true; - quantity: number; -} - -interface UnknownCard { - name: null; - is_revealed: false; - quantity: null; -} +const titleRangeSchema = z.enum(TitleRanges); + +export type TitleRange = z.infer; + +// knowns cards have most field filled in except is_matched +const knownCardSchema = z.object({ + name: z.string(), + is_revealed: z.literal(true), + quantity: z.coerce.number(), + is_matched: z.union([z.literal(true), z.literal(null)]), +}); + +// unknown cards have all null fields +const unknownCardSchema = z.object({ + name: z.literal(null), + is_revealed: z.literal(null), + quantity: z.literal(null), + is_matched: z.literal(null), +}); + +// cards can be either known or unknown but will always have an numeric id +const cardSchema = z.object({ + id: z.coerce.number(), +}).and(z.discriminatedUnion('is_revealed', [knownCardSchema, unknownCardSchema])); + +const rewardTierSchema = z.object({ + type: titleRangeSchema, + name: z.string(), // A readable/nice english string of the title range +}); + +const spookyShuffleStatusSchema = z.object({ + is_complete: z.boolean().nullable(), + is_upgraded: z.boolean().nullable(), + has_selected_testing_pair: z.boolean(), + reward_tiers: z.array(rewardTierSchema), + title_range: titleRangeSchema, + cards: z.array(cardSchema), +}); + +export type SpookyShuffleStatus = z.infer; + +export const spookyShuffleResponseSchema = hgResponseSchema.extend({ + memory_game: spookyShuffleStatusSchema, +}); + +export type SpookyShuffleResponse = z.infer; diff --git a/tests/scripts/modules/ajax-handlers/spookyShuffle.spec.ts b/tests/scripts/modules/ajax-handlers/spookyShuffle.spec.ts index 0ce3edff..418d9e32 100644 --- a/tests/scripts/modules/ajax-handlers/spookyShuffle.spec.ts +++ b/tests/scripts/modules/ajax-handlers/spookyShuffle.spec.ts @@ -1,6 +1,5 @@ import {SpookyShuffleAjaxHandler} from "@scripts/modules/ajax-handlers"; -import {SpookyShuffleStatus} from "@scripts/modules/ajax-handlers/spookyShuffle.types"; -import {HgResponse} from "@scripts/types/hg"; +import {SpookyShuffleResponse, SpookyShuffleStatus} from "@scripts/modules/ajax-handlers/spookyShuffle.types"; import {HgItem} from "@scripts/types/mhct"; jest.mock('@scripts/util/logger'); @@ -9,6 +8,7 @@ jest.mock('@scripts/util/hgFunctions'); import {ConsoleLogger} from '@scripts/util/logger'; import {getItemsByClass} from "@scripts/util/hgFunctions"; import {CustomConvertibleIds} from "@scripts/util/constants"; +import {HgResponseBuilder} from "@tests/utility/builders"; const logger = new ConsoleLogger(); const submitConvertibleCallback = jest.fn() as jest.MockedFunction<(convertible: HgItem, items: HgItem[]) => void>; @@ -18,6 +18,9 @@ const mockedGetItemsByClass = jest.mocked(getItemsByClass); const spookyShuffle_url = "mousehuntgame.com/managers/ajax/events/spooky_shuffle.php"; describe("SpookyShuffleAjaxHandler", () => { + + const responseBuilder = new HgResponseBuilder(); + beforeEach(() => { jest.clearAllMocks(); jest.resetModules(); @@ -35,8 +38,10 @@ describe("SpookyShuffleAjaxHandler", () => { describe("execute", () => { it('warns if response is unexpected', async () => { + // memory_game missing here, - const response = {user_id: 4} as unknown as HgResponse; + const response = responseBuilder.build(); + await handler.execute(response); expect(logger.warn).toHaveBeenCalledWith('Unexpected spooky shuffle response object.', expect.anything()); @@ -52,7 +57,11 @@ describe("SpookyShuffleAjaxHandler", () => { title_range: 'novice_journeyman', cards: [], }; - await handler.execute({memory_game: result} as unknown as HgResponse); + const response: SpookyShuffleResponse = { + ...responseBuilder.build(), + memory_game: result, + }; + await handler.execute(response); expect(logger.debug).toHaveBeenCalledWith('Spooky Shuffle board is not complete yet.'); expect(submitConvertibleCallback).toHaveBeenCalledTimes(0); @@ -67,7 +76,12 @@ describe("SpookyShuffleAjaxHandler", () => { title_range: 'novice_journeyman', cards: [], }; - await handler.execute({memory_game: result} as unknown as HgResponse); + const response: SpookyShuffleResponse = { + ...responseBuilder.build(), + memory_game: result, + }; + + await handler.execute(response); expect(logger.debug).toHaveBeenCalledWith('Spooky Shuffle board is not complete yet.'); expect(submitConvertibleCallback).toHaveBeenCalledTimes(0); @@ -98,12 +112,17 @@ describe("SpookyShuffleAjaxHandler", () => { name: 'Test Item', is_matched: true, is_revealed: true, - is_tested_pair: true, quantity: 567, }, ], }; - await handler.execute({memory_game: result} as unknown as HgResponse); + const response: SpookyShuffleResponse = { + ...responseBuilder.build(), + memory_game: result, + }; + + await handler.execute(response); + const expectedConvertible = { id: CustomConvertibleIds.HalloweenSpookyShuffleNovice, name: 'Spooky Shuffle (Test Title Range)', @@ -150,7 +169,6 @@ describe("SpookyShuffleAjaxHandler", () => { name: 'Test Item', is_matched: true, is_revealed: true, - is_tested_pair: true, quantity: 567, }, { @@ -158,12 +176,17 @@ describe("SpookyShuffleAjaxHandler", () => { name: 'Gold', is_matched: true, is_revealed: true, - is_tested_pair: true, quantity: 5000, }, ], }; - await handler.execute({memory_game: result} as unknown as HgResponse); + const response: SpookyShuffleResponse = { + ...responseBuilder.build(), + memory_game: result, + }; + + await handler.execute(response); + const expectedConvertible = { id: CustomConvertibleIds.HalloweenSpookyShuffleGrandDukeDusted, name: 'Upgraded Spooky Shuffle (Grand Test Title and up)', @@ -214,12 +237,16 @@ describe("SpookyShuffleAjaxHandler", () => { name: 'Test Item', is_matched: true, is_revealed: true, - is_tested_pair: true, quantity: 567, }, ], }; - await handler.execute({memory_game: result} as unknown as HgResponse); + const response: SpookyShuffleResponse = { + ...responseBuilder.build(), + memory_game: result, + }; + + await handler.execute(response); expect(logger.warn).toHaveBeenCalledWith(`Item 'Test Item' wasn't found in item map. Check its classification type`); expect(submitConvertibleCallback).not.toHaveBeenCalled();