diff --git a/src/domain/cart/policy/__spec__/ShippingPolicy.spec.ts b/src/domain/cart/policy/__spec__/ShippingPolicy.spec.ts index a8986fc..baed90a 100644 --- a/src/domain/cart/policy/__spec__/ShippingPolicy.spec.ts +++ b/src/domain/cart/policy/__spec__/ShippingPolicy.spec.ts @@ -2,30 +2,33 @@ import { describe, expect, it } from "vitest"; import { Money } from "@/domain/cart/value-objects/Money"; import { ShippingPolicy } from "../ShippingPolicy"; +const threshold = ShippingPolicy.SHIPPING_THRESHOLD.toAmount(); +const cost = ShippingPolicy.SHIPPING_COST.toAmount(); + describe("ShippingPolicy", () => { it("should return 0 shipping if subtotal is equal to or above the threshold", () => { - const subtotalAtThreshold = new Money(50); - const subtotalAboveThreshold = new Money(100); + const subtotalAtThreshold = new Money(threshold); + const subtotalAboveThreshold = new Money(threshold + 100); expect(ShippingPolicy.calculate(subtotalAtThreshold).toAmount()).toBe(0); expect(ShippingPolicy.calculate(subtotalAboveThreshold).toAmount()).toBe(0); }); it("should return the shipping cost if subtotal is below the threshold", () => { - const subtotalBelowThreshold = new Money(25); + const subtotalBelowThreshold = new Money(threshold - 100); const shippingCost = ShippingPolicy.calculate(subtotalBelowThreshold); - expect(shippingCost.toAmount()).toBe(5.99); + expect(shippingCost.toAmount()).toBe(cost); }); it("should correctly handle edge case just below threshold", () => { - const subtotalJustBelow = new Money(49.99); + const subtotalJustBelow = new Money(threshold - 0.01); const shippingCost = ShippingPolicy.calculate(subtotalJustBelow); - expect(shippingCost.toAmount()).toBe(5.99); + expect(shippingCost.toAmount()).toBe(cost); }); it("should correctly handle edge case just above threshold", () => { - const subtotalJustAbove = new Money(50.01); + const subtotalJustAbove = new Money(threshold + 0.01); const shippingCost = ShippingPolicy.calculate(subtotalJustAbove); expect(shippingCost.toAmount()).toBe(0); }); diff --git a/src/domain/cart/value-objects/Product/Product.ts b/src/domain/cart/value-objects/Product/Product.ts index d7ff382..8ce8250 100644 --- a/src/domain/cart/value-objects/Product/Product.ts +++ b/src/domain/cart/value-objects/Product/Product.ts @@ -109,14 +109,10 @@ export class Product { } getLoreLink(level: KnowledgeProfile): string { - if (level === "beginner") return this._knowledge.getProfileLoreLink(level); - if (level === "adventurer") return this._knowledge.getProfileLoreLink(level); return this._knowledge.getProfileLoreLink(level); } getVideoUrl(level: KnowledgeProfile): string { - if (level === "beginner") return this._knowledge.getProfileVideoUrl(level); - if (level === "adventurer") return this._knowledge.getProfileVideoUrl(level); return this._knowledge.getProfileVideoUrl(level); } } diff --git a/src/domain/cart/value-objects/Product/__spec__/Product.spec.ts b/src/domain/cart/value-objects/Product/__spec__/Product.spec.ts index 36470bc..ee97d60 100644 --- a/src/domain/cart/value-objects/Product/__spec__/Product.spec.ts +++ b/src/domain/cart/value-objects/Product/__spec__/Product.spec.ts @@ -24,12 +24,27 @@ describe("Product Value Objects", () => { }); describe("KnowledgeContent and Product", () => { - const stats = new Stats(10, 20, 5); - const effects = [new Effect("Fire damage")]; - const extraResources = new ExtraResources("https://lore.link", "https://video.link"); - const profile = new ProfileDetails("A legendary sword", stats, effects, extraResources); + const statsByLevel = { + beginner: { power: 1, durability: 2, manaBoost: 3 }, + adventurer: { power: 4, durability: 5, manaBoost: 6 }, + expert: { power: 7, durability: 8, manaBoost: 9 }, + } as const; - const knowledge = new KnowledgeContent(profile, profile, profile); + const profileFor = (level: "beginner" | "adventurer" | "expert") => { + const { power, durability, manaBoost } = statsByLevel[level]; + return new ProfileDetails( + `${level} tale`, + new Stats(power, durability, manaBoost), + [new Effect(`${level} effect`)], + new ExtraResources(`https://lore.link/${level}`, `https://video.link/${level}`), + ); + }; + + const knowledge = new KnowledgeContent( + profileFor("beginner"), + profileFor("adventurer"), + profileFor("expert"), + ); const product = new Product( new ProductId("sword01"), @@ -46,23 +61,14 @@ describe("KnowledgeContent and Product", () => { expect(product.displayImage()).toBe("http://image.link"); }); - it("should return profile description, effects, stats, lore link, and video URL", () => { - const levels: ("beginner" | "adventurer" | "expert")[] = [ - "beginner", - "adventurer", - "expert", - ]; - - for (const level of levels) { - expect(product.describeProfile(level)).toBe("A legendary sword"); - expect(product.listProfileEffects(level)).toEqual(["Fire damage"]); - expect(product.getProfileStats(level)).toEqual({ - power: 10, - durability: 20, - manaBoost: 5, - }); - expect(product.getLoreLink(level)).toBe("https://lore.link"); - expect(product.getVideoUrl(level)).toBe("https://video.link"); - } - }); + it.each(["beginner", "adventurer", "expert"] as const)( + "should route %s to its own profile's description, effects, stats, lore link, and video URL", + (level) => { + expect(product.describeProfile(level)).toBe(`${level} tale`); + expect(product.listProfileEffects(level)).toEqual([`${level} effect`]); + expect(product.getProfileStats(level)).toEqual(statsByLevel[level]); + expect(product.getLoreLink(level)).toBe(`https://lore.link/${level}`); + expect(product.getVideoUrl(level)).toBe(`https://video.link/${level}`); + }, + ); }); diff --git a/src/domain/cart/value-objects/__spec__/Product.spec.ts b/src/domain/cart/value-objects/__spec__/Product.spec.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/LocalStorageWelcomeSurvey.repository.ts b/src/ui/components/WelcomeModal/WelcomeStorage/LocalStorageWelcomeSurvey.repository.ts index b312eb6..ff0f7f6 100644 --- a/src/ui/components/WelcomeModal/WelcomeStorage/LocalStorageWelcomeSurvey.repository.ts +++ b/src/ui/components/WelcomeModal/WelcomeStorage/LocalStorageWelcomeSurvey.repository.ts @@ -10,7 +10,7 @@ import { export class LocalStorageWelcomeSurveyRepository implements WelcomeStorageRepository { private listeners = new Set(); - private cache: WelcomeSurveyStoreState = { survey: null }; + private cache: WelcomeSurveyStoreState | undefined; getSurvey = (): WelcomeSurveyStoreState["survey"] => { if (this.cache !== undefined) { diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/TanstackStoreWelcomeSurvey.repository.ts b/src/ui/components/WelcomeModal/WelcomeStorage/TanstackStoreWelcomeSurvey.repository.ts index 4d8d442..1d4c09a 100644 --- a/src/ui/components/WelcomeModal/WelcomeStorage/TanstackStoreWelcomeSurvey.repository.ts +++ b/src/ui/components/WelcomeModal/WelcomeStorage/TanstackStoreWelcomeSurvey.repository.ts @@ -6,7 +6,14 @@ import { type WelcomeSurveyStoreState, } from "@/domain/welcomeSurvey/WelcomeSurveyStore.config"; +// This module runs at import time both in the browser and, once the app is prerendered +// (see the SEO PR later in the stack), under Node during a server-side render. Node has +// no localStorage, so every access is guarded rather than assumed. +const isBrowser = typeof localStorage !== "undefined"; + const loadInitialState = (): WelcomeSurveyStoreState => { + if (!isBrowser) return { survey: null }; + try { const stored = localStorage.getItem(WELCOME_SURVEY_KEY); @@ -22,6 +29,8 @@ const loadInitialState = (): WelcomeSurveyStoreState => { const welcomeStore = new Store(loadInitialState()); welcomeStore.subscribe(() => { + if (!isBrowser) return; + try { localStorage.setItem(WELCOME_SURVEY_KEY, JSON.stringify(welcomeStore.state)); } catch (error) { diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/__spec__/LocalStorageWelcomeSurvey.repository.spec.ts b/src/ui/components/WelcomeModal/WelcomeStorage/__spec__/LocalStorageWelcomeSurvey.repository.spec.ts new file mode 100644 index 0000000..f452b34 --- /dev/null +++ b/src/ui/components/WelcomeModal/WelcomeStorage/__spec__/LocalStorageWelcomeSurvey.repository.spec.ts @@ -0,0 +1,61 @@ +/** @vitest-environment jsdom */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { WELCOME_SURVEY_KEY } from "@/domain/welcomeSurvey/WelcomeSurveyStore.config"; +import { LocalStorageWelcomeSurveyRepository } from "../LocalStorageWelcomeSurvey.repository"; + +describe("LocalStorageWelcomeSurveyRepository", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("should return null when nothing has been saved yet", () => { + const repository = new LocalStorageWelcomeSurveyRepository(); + + expect(repository.getSurvey()).toBeNull(); + }); + + it("should persist and read back a saved survey", () => { + const repository = new LocalStorageWelcomeSurveyRepository(); + + repository.saveSurvey({ skill: "expert" }); + + expect(repository.getSurvey()).toEqual({ skill: "expert" }); + }); + + it("should read a survey that was written to storage by another instance", () => { + const writer = new LocalStorageWelcomeSurveyRepository(); + writer.saveSurvey({ skill: "intermediate" }); + + const reader = new LocalStorageWelcomeSurveyRepository(); + + expect(reader.getSurvey()).toEqual({ skill: "intermediate" }); + }); + + it("should fall back to null for malformed stored data", () => { + localStorage.setItem(WELCOME_SURVEY_KEY, "not json"); + const repository = new LocalStorageWelcomeSurveyRepository(); + + expect(repository.getSurvey()).toBeNull(); + }); + + it("should notify subscribers when a survey is saved", () => { + const repository = new LocalStorageWelcomeSurveyRepository(); + const listener = vi.fn(); + repository.subscribe(listener); + + repository.saveSurvey({ skill: "beginner" }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("should stop notifying once unsubscribed", () => { + const repository = new LocalStorageWelcomeSurveyRepository(); + const listener = vi.fn(); + const unsubscribe = repository.subscribe(listener); + unsubscribe(); + + repository.saveSurvey({ skill: "beginner" }); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/__spec__/TanstackStoreWelcomeSurvey.repository.spec.ts b/src/ui/components/WelcomeModal/WelcomeStorage/__spec__/TanstackStoreWelcomeSurvey.repository.spec.ts new file mode 100644 index 0000000..4288a71 --- /dev/null +++ b/src/ui/components/WelcomeModal/WelcomeStorage/__spec__/TanstackStoreWelcomeSurvey.repository.spec.ts @@ -0,0 +1,35 @@ +// This file has no jsdom environment override, so it runs in vitest's default node +// environment, which has no localStorage. That intentionally proves the module is safe +// to import and use during a server-side render (see the SEO PR later in the stack). +import { describe, expect, it, vi } from "vitest"; +import { TanStackStoreWelcomeSurveyRepository } from "../TanstackStoreWelcomeSurvey.repository"; + +// TanStackStoreWelcomeSurveyRepository wraps one module-level store shared by every +// instance, so these tests read back whatever the previous one just wrote rather than +// assuming a clean starting state. +describe("TanStackStoreWelcomeSurveyRepository (no localStorage)", () => { + it("should be importable and constructible without a DOM", () => { + expect(() => new TanStackStoreWelcomeSurveyRepository()).not.toThrow(); + }); + + it("should save and read back a survey in memory", () => { + const repository = new TanStackStoreWelcomeSurveyRepository(); + + repository.saveSurvey({ skill: "expert" }); + + expect(repository.getSurvey()).toEqual({ skill: "expert" }); + }); + + it("should notify and allow unsubscribing", () => { + const repository = new TanStackStoreWelcomeSurveyRepository(); + const listener = vi.fn(); + const unsubscribe = repository.subscribe(listener); + + repository.saveSurvey({ skill: "beginner" }); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + repository.saveSurvey({ skill: "intermediate" }); + expect(listener).toHaveBeenCalledTimes(1); + }); +});