From db2ee3ec6cd3e19957143b13f825412794b6709f Mon Sep 17 00:00:00 2001 From: mortezaei Date: Tue, 18 Aug 2026 02:09:33 +0330 Subject: [PATCH] fix question storage --- .../Componentes/question-answer-storage.tsx | 12 +- .../Componentes/slider-page.test.tsx | 1 + .../Componentes/test-questions-flow.tsx | 2 +- src/lib/user-scoped-storage.test.ts | 163 ++++++++++++++++++ 4 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 src/lib/user-scoped-storage.test.ts diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index 98660b0..3dd58f6 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -553,14 +553,12 @@ export function QuestionAnswersProvider({ questionsRef.current, backendFieldsRef.current, ); - const nextDirtyKey = fullPayload.fields.find((field) => + const pendingFields = fullPayload.fields.filter((field) => dirtyKeysRef.current.has(field.key), - )?.key; + ); const payload = { ...fullPayload, - fields: nextDirtyKey - ? fullPayload.fields.filter((field) => field.key === nextDirtyKey) - : [], + fields: pendingFields, }; const revision = answersRevisionRef.current; @@ -613,7 +611,9 @@ export function QuestionAnswersProvider({ return; } - if (nextDirtyKey) dirtyKeysRef.current.delete(nextDirtyKey); + payload.fields.forEach((field) => { + dirtyKeysRef.current.delete(field.key); + }); if (dirtyKeysRef.current.size > 0) { await flushAnswersRef.current(); diff --git a/src/components/Componentes/slider-page.test.tsx b/src/components/Componentes/slider-page.test.tsx index 69f9242..b0f678c 100644 --- a/src/components/Componentes/slider-page.test.tsx +++ b/src/components/Componentes/slider-page.test.tsx @@ -25,6 +25,7 @@ vi.mock('@/hooks/marriage/use-profile-main', () => ({ vi.mock('@/hooks/marriage/query-keys', () => ({ marriageQueryKeys: { profile: () => ['marriage', 'profile'], + config: () => ['marriage', 'config'], }, })); diff --git a/src/components/Componentes/test-questions-flow.tsx b/src/components/Componentes/test-questions-flow.tsx index 0a933ac..d7e674a 100644 --- a/src/components/Componentes/test-questions-flow.tsx +++ b/src/components/Componentes/test-questions-flow.tsx @@ -35,7 +35,7 @@ type TestQuestionsFlowProps = { stepsLabel?: string; onFinish?: (answers: Record) => void; onClose?: () => void; - draftStorageKey?: string; + draftStorageKey?: string | null; }; type StoredTestDraft = { answers?: Record; diff --git a/src/lib/user-scoped-storage.test.ts b/src/lib/user-scoped-storage.test.ts new file mode 100644 index 0000000..e90004b --- /dev/null +++ b/src/lib/user-scoped-storage.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + SCOPED_STORAGE_VERSION, + getScopedSectionDraftKey, + getScopedAssessmentDraftKey, + readScopedSectionDraft, + readScopedAssessmentDraft, + writeScopedSectionDraft, + writeScopedAssessmentDraft, + removeScopedSectionDraft, + removeScopedAssessmentDraft, + migrateLegacyStorageKeys, + isLegacyMigrationDone, + isDraftOwnedBy, + getAllScopedSectionDraftKeys, + extractSlugFromScopedKey, +} from "./user-scoped-storage"; + +describe("user-scoped-storage", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe("Key generators", () => { + it("generates profile-scoped section draft keys with version", () => { + const key = getScopedSectionDraftKey(42, "personal_info"); + expect(key).toBe(`marriage:user:42:sections:personal_info:draft:v${SCOPED_STORAGE_VERSION}`); + }); + + it("generates profile-scoped assessment draft keys with version", () => { + const key = getScopedAssessmentDraftKey(42, "personality_test"); + expect(key).toBe(`marriage:user:42:tests:personality_test:draft:v${SCOPED_STORAGE_VERSION}`); + }); + + it("extracts slug correctly from scoped key", () => { + const key = getScopedSectionDraftKey(101, "family_background"); + expect(extractSlugFromScopedKey(key)).toBe("family_background"); + }); + }); + + describe("Section draft isolation across accounts", () => { + it("isolates Account A edits from Account B", () => { + const accountAProfileId = 1001; + const accountBProfileId = 2002; + + // Account A writes a draft + writeScopedSectionDraft(accountAProfileId, "job_info", { + job_title: { + key: "job_title", + label: "Job Title", + type: "text", + value: "Engineer", + }, + }); + + // Account A can read it + const draftA = readScopedSectionDraft(accountAProfileId, "job_info"); + expect(draftA).not.toBeNull(); + expect(draftA?.pending.job_title.value).toBe("Engineer"); + expect(draftA?.ownerProfileId).toBe(accountAProfileId); + + // Account B CANNOT read Account A's draft + const draftB = readScopedSectionDraft(accountBProfileId, "job_info"); + expect(draftB).toBeNull(); + }); + + it("removes draft when pending is empty", () => { + const profileId = 123; + writeScopedSectionDraft(profileId, "bio", { + about: { key: "about", label: "About", type: "text", value: "Hello" }, + }); + expect(readScopedSectionDraft(profileId, "bio")).not.toBeNull(); + + // Write empty pending map -> draft is removed + writeScopedSectionDraft(profileId, "bio", {}); + expect(readScopedSectionDraft(profileId, "bio")).toBeNull(); + }); + + it("removes draft explicitly via removeScopedSectionDraft", () => { + const profileId = 123; + writeScopedSectionDraft(profileId, "bio", { + about: { key: "about", label: "About", type: "text", value: "Hello" }, + }); + removeScopedSectionDraft(profileId, "bio"); + expect(readScopedSectionDraft(profileId, "bio")).toBeNull(); + }); + + it("lists all scoped draft keys for a given profile", () => { + writeScopedSectionDraft(1, "sec_1", { q: { key: "q", label: "Q", type: "text", value: "A" } }); + writeScopedSectionDraft(1, "sec_2", { q: { key: "q", label: "Q", type: "text", value: "B" } }); + writeScopedSectionDraft(2, "sec_3", { q: { key: "q", label: "Q", type: "text", value: "C" } }); + + const keys1 = getAllScopedSectionDraftKeys(1); + expect(keys1).toHaveLength(2); + expect(keys1.map(extractSlugFromScopedKey)).toEqual(expect.arrayContaining(["sec_1", "sec_2"])); + + const keys2 = getAllScopedSectionDraftKeys(2); + expect(keys2).toHaveLength(1); + expect(extractSlugFromScopedKey(keys2[0])).toBe("sec_3"); + }); + }); + + describe("Assessment draft isolation across accounts", () => { + it("isolates assessment answers across accounts", () => { + const userA = 501; + const userB = 502; + + writeScopedAssessmentDraft(userA, "personality_test", { 1: "A", 2: "B" }, 2, 10); + + const draftA = readScopedAssessmentDraft(userA, "personality_test"); + expect(draftA).not.toBeNull(); + expect(draftA?.answers).toEqual({ 1: "A", 2: "B" }); + expect(draftA?.currentIndex).toBe(2); + expect(draftA?.totalQuestions).toBe(10); + + const draftB = readScopedAssessmentDraft(userB, "personality_test"); + expect(draftB).toBeNull(); + }); + + it("removes assessment draft explicitly", () => { + writeScopedAssessmentDraft(100, "glasser_5_needs_test", { 1: 5 }, 1, 20); + removeScopedAssessmentDraft(100, "glasser_5_needs_test"); + expect(readScopedAssessmentDraft(100, "glasser_5_needs_test")).toBeNull(); + }); + }); + + describe("Legacy migration", () => { + it("removes old unscoped keys while preserving unrelated localStorage", () => { + // Setup old keys + window.localStorage.setItem("marriage:sections:about:answers:v2", JSON.stringify({ fields: [] })); + window.localStorage.setItem("marriage:tests:personality_test:draft", JSON.stringify({ answers: { 1: "A" } })); + window.localStorage.setItem("marriage:sections:glasser_5_needs_test:answers", JSON.stringify({ completed: true })); + + // Setup unrelated keys that MUST NOT be touched + window.localStorage.setItem("marriage:device_id", "device-12345"); + window.localStorage.setItem("theme_preference", "dark"); + + expect(isLegacyMigrationDone()).toBe(false); + + migrateLegacyStorageKeys(); + + expect(isLegacyMigrationDone()).toBe(true); + + // Old keys should be gone + expect(window.localStorage.getItem("marriage:sections:about:answers:v2")).toBeNull(); + expect(window.localStorage.getItem("marriage:tests:personality_test:draft")).toBeNull(); + expect(window.localStorage.getItem("marriage:sections:glasser_5_needs_test:answers")).toBeNull(); + + // Unrelated keys must be preserved + expect(window.localStorage.getItem("marriage:device_id")).toBe("device-12345"); + expect(window.localStorage.getItem("theme_preference")).toBe("dark"); + }); + }); + + describe("Ownership verification helper", () => { + it("verifies draft ownership correctly", () => { + expect(isDraftOwnedBy({ ownerProfileId: 99 }, 99)).toBe(true); + expect(isDraftOwnedBy({ ownerProfileId: 99 }, 100)).toBe(false); + expect(isDraftOwnedBy(null, 99)).toBe(false); + expect(isDraftOwnedBy(undefined, 99)).toBe(false); + }); + }); +});