Browse Source

fix question storage

master
mortezaei 7 days ago
parent
commit
db2ee3ec6c
  1. 12
      src/components/Componentes/question-answer-storage.tsx
  2. 1
      src/components/Componentes/slider-page.test.tsx
  3. 2
      src/components/Componentes/test-questions-flow.tsx
  4. 163
      src/lib/user-scoped-storage.test.ts

12
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();

1
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'],
},
}));

2
src/components/Componentes/test-questions-flow.tsx

@ -35,7 +35,7 @@ type TestQuestionsFlowProps = {
stepsLabel?: string;
onFinish?: (answers: Record<number, string | number>) => void;
onClose?: () => void;
draftStorageKey?: string;
draftStorageKey?: string | null;
};
type StoredTestDraft = {
answers?: Record<number, string | number>;

163
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);
});
});
});
Loading…
Cancel
Save