Skip to content

Commit 9e609aa

Browse files
test: add unit tests for internal helpers and storage functionalities
1 parent d1aed10 commit 9e609aa

2 files changed

Lines changed: 339 additions & 1 deletion

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { StorageScope } from "../Storage.types";
2+
import {
3+
NATIVE_BATCH_MISSING_SENTINEL,
4+
assertBatchScope,
5+
assertValidScope,
6+
decodeNativeBatchValue,
7+
deserializeWithPrimitiveFastPath,
8+
isStoredEnvelope,
9+
serializeWithPrimitiveFastPath,
10+
} from "../internal";
11+
12+
describe("internal helpers", () => {
13+
it("detects stored envelope shapes", () => {
14+
expect(isStoredEnvelope(null)).toBe(false);
15+
expect(isStoredEnvelope("nope")).toBe(false);
16+
expect(
17+
isStoredEnvelope({
18+
__nitroStorageEnvelope: true,
19+
expiresAt: 1234,
20+
payload: "value",
21+
})
22+
).toBe(true);
23+
expect(
24+
isStoredEnvelope({
25+
__nitroStorageEnvelope: true,
26+
expiresAt: "1234",
27+
payload: "value",
28+
})
29+
).toBe(false);
30+
});
31+
32+
it("validates scopes", () => {
33+
expect(() => assertValidScope(StorageScope.Memory)).not.toThrow();
34+
expect(() => assertValidScope(StorageScope.Disk)).not.toThrow();
35+
expect(() => assertValidScope(StorageScope.Secure)).not.toThrow();
36+
expect(() => assertValidScope(999 as StorageScope)).toThrow(/Invalid storage scope/);
37+
});
38+
39+
it("validates batch item scopes and reports scope names", () => {
40+
expect(() =>
41+
assertBatchScope(
42+
[
43+
{ key: "a", scope: StorageScope.Disk },
44+
{ key: "b", scope: StorageScope.Disk },
45+
],
46+
StorageScope.Disk
47+
)
48+
).not.toThrow();
49+
50+
expect(() =>
51+
assertBatchScope(
52+
[{ key: "bad-key", scope: 999 as StorageScope }],
53+
StorageScope.Disk
54+
)
55+
).toThrow(/expected Disk, received 999/);
56+
});
57+
58+
it("decodes native missing sentinel", () => {
59+
expect(decodeNativeBatchValue(NATIVE_BATCH_MISSING_SENTINEL)).toBeUndefined();
60+
expect(decodeNativeBatchValue("raw")).toBe("raw");
61+
});
62+
63+
it("serializes primitives via fast path and falls back to JSON", () => {
64+
expect(serializeWithPrimitiveFastPath("hello")).toBe("__nitro_storage_primitive__:s:hello");
65+
expect(serializeWithPrimitiveFastPath(42)).toBe("__nitro_storage_primitive__:n:42");
66+
expect(serializeWithPrimitiveFastPath(true)).toBe("__nitro_storage_primitive__:b:1");
67+
expect(serializeWithPrimitiveFastPath(false)).toBe("__nitro_storage_primitive__:b:0");
68+
expect(serializeWithPrimitiveFastPath(undefined)).toBe("__nitro_storage_primitive__:u");
69+
expect(serializeWithPrimitiveFastPath(null)).toBe("__nitro_storage_primitive__:l");
70+
expect(serializeWithPrimitiveFastPath(Number.POSITIVE_INFINITY)).toBe("null");
71+
expect(serializeWithPrimitiveFastPath({ nested: "ok" })).toBe('{"nested":"ok"}');
72+
});
73+
74+
it("throws when default serialization cannot produce a string", () => {
75+
expect(() => serializeWithPrimitiveFastPath(() => undefined)).toThrow(
76+
/Unable to serialize value/
77+
);
78+
});
79+
80+
it("deserializes fast-path values and JSON fallback values", () => {
81+
expect(deserializeWithPrimitiveFastPath<string>("__nitro_storage_primitive__:s:value")).toBe(
82+
"value"
83+
);
84+
expect(deserializeWithPrimitiveFastPath<number>("__nitro_storage_primitive__:n:123")).toBe(
85+
123
86+
);
87+
expect(deserializeWithPrimitiveFastPath<boolean>("__nitro_storage_primitive__:b:1")).toBe(
88+
true
89+
);
90+
expect(deserializeWithPrimitiveFastPath<boolean>("__nitro_storage_primitive__:b:0")).toBe(
91+
false
92+
);
93+
expect(
94+
deserializeWithPrimitiveFastPath<undefined>("__nitro_storage_primitive__:u")
95+
).toBeUndefined();
96+
expect(deserializeWithPrimitiveFastPath<null>("__nitro_storage_primitive__:l")).toBeNull();
97+
98+
expect(
99+
deserializeWithPrimitiveFastPath<string>("__nitro_storage_primitive__:n:not-a-number")
100+
).toBe("__nitro_storage_primitive__:n:not-a-number");
101+
expect(deserializeWithPrimitiveFastPath<{ ok: boolean }>('{"ok":true}')).toEqual({
102+
ok: true,
103+
});
104+
expect(deserializeWithPrimitiveFastPath<string>("legacy-raw-value")).toBe("legacy-raw-value");
105+
});
106+
});

packages/react-native-nitro-storage/src/__tests__/web-storage.test.ts

Lines changed: 233 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
useStorageSelector,
1515
useStorage,
1616
} from "../index.web";
17-
import { serializeWithPrimitiveFastPath } from "../internal";
17+
import { MIGRATION_VERSION_KEY, serializeWithPrimitiveFastPath } from "../internal";
1818

1919
function createStorageMock(): Storage {
2020
const store = new Map<string, string>();
@@ -606,4 +606,236 @@ describe("Web Storage", () => {
606606

607607
expect(migrateToLatest(StorageScope.Memory)).toBe(version);
608608
});
609+
610+
it("reads pending secure values before coalesced writes flush", async () => {
611+
const item = createStorageItem({
612+
key: "pending-secure-read",
613+
scope: StorageScope.Secure,
614+
defaultValue: "default",
615+
coalesceSecureWrites: true,
616+
});
617+
618+
item.set("queued");
619+
expect(item.get()).toBe("queued");
620+
621+
await Promise.resolve();
622+
expect(globalThis.sessionStorage.getItem("pending-secure-read")).toBe(
623+
serializeWithPrimitiveFastPath("queued")
624+
);
625+
});
626+
627+
it("keeps direct and coalesced secure write paths independent", async () => {
628+
const coalesced = createStorageItem({
629+
key: "queued-secure",
630+
scope: StorageScope.Secure,
631+
defaultValue: "default",
632+
coalesceSecureWrites: true,
633+
});
634+
const direct = createStorageItem({
635+
key: "direct-secure",
636+
scope: StorageScope.Secure,
637+
defaultValue: "default",
638+
});
639+
const setSpy = jest.spyOn(globalThis.sessionStorage, "setItem");
640+
const removeSpy = jest.spyOn(globalThis.sessionStorage, "removeItem");
641+
642+
coalesced.set("queued-1");
643+
direct.set("direct-1");
644+
coalesced.delete();
645+
direct.delete();
646+
647+
expect(setSpy).toHaveBeenCalledWith(
648+
"direct-secure",
649+
serializeWithPrimitiveFastPath("direct-1")
650+
);
651+
expect(removeSpy).toHaveBeenCalledWith("direct-secure");
652+
653+
await Promise.resolve();
654+
expect(removeSpy).toHaveBeenCalledWith("queued-secure");
655+
});
656+
657+
it("handles memory TTL expiration and delete cleanup", () => {
658+
const nowSpy = jest.spyOn(Date, "now").mockReturnValue(1_000);
659+
const listener = jest.fn();
660+
const item = createStorageItem<string>({
661+
key: "memory-ttl-web",
662+
scope: StorageScope.Memory,
663+
defaultValue: "fallback",
664+
expiration: { ttlMs: 10 },
665+
});
666+
667+
item.subscribe(listener);
668+
item.set("live");
669+
expect(item.get()).toBe("live");
670+
671+
nowSpy.mockReturnValue(1_020);
672+
expect(item.get()).toBe("fallback");
673+
674+
item.set("second");
675+
item.delete();
676+
expect(item.get()).toBe("fallback");
677+
expect(listener).toHaveBeenCalled();
678+
nowSpy.mockRestore();
679+
});
680+
681+
it("uses cache and pending secure paths in batch reads", async () => {
682+
const diskGetSpy = jest.spyOn(globalThis.localStorage, "getItem");
683+
const cachedDisk = createStorageItem({
684+
key: "disk-batch-cache",
685+
scope: StorageScope.Disk,
686+
defaultValue: "default",
687+
readCache: true,
688+
});
689+
cachedDisk.set("cached-value");
690+
diskGetSpy.mockClear();
691+
692+
expect(getBatch([cachedDisk], StorageScope.Disk)).toEqual(["cached-value"]);
693+
expect(diskGetSpy).toHaveBeenCalledTimes(0);
694+
695+
const pendingSecure = createStorageItem({
696+
key: "secure-batch-pending",
697+
scope: StorageScope.Secure,
698+
defaultValue: "default",
699+
coalesceSecureWrites: true,
700+
});
701+
pendingSecure.set("queued-secure-value");
702+
expect(getBatch([pendingSecure], StorageScope.Secure)).toEqual([
703+
"queued-secure-value",
704+
]);
705+
706+
await Promise.resolve();
707+
});
708+
709+
it("falls back to item.get in web getBatch when raw value is missing", () => {
710+
const item = createStorageItem({
711+
key: "web-batch-fallback",
712+
scope: StorageScope.Disk,
713+
defaultValue: "fallback",
714+
});
715+
716+
expect(getBatch([item], StorageScope.Disk)).toEqual(["fallback"]);
717+
});
718+
719+
it("uses per-item fallback path for non-raw batch set items", () => {
720+
const validatedItem = createStorageItem<number>({
721+
key: "batch-web-fallback-valid",
722+
scope: StorageScope.Disk,
723+
defaultValue: 1,
724+
validate: (value): value is number => typeof value === "number" && value > 0,
725+
});
726+
727+
setBatch([{ item: validatedItem, value: 9 }], StorageScope.Disk);
728+
expect(validatedItem.get()).toBe(9);
729+
});
730+
731+
it("handles missing browser storage in web batch operations", () => {
732+
const originalLocalStorage = globalThis.localStorage;
733+
try {
734+
Object.defineProperty(globalThis, "localStorage", {
735+
value: undefined,
736+
configurable: true,
737+
writable: true,
738+
});
739+
740+
const item = createStorageItem({
741+
key: "missing-storage-batch",
742+
scope: StorageScope.Disk,
743+
defaultValue: "default",
744+
});
745+
746+
expect(() =>
747+
setBatch([{ item, value: "next" }], StorageScope.Disk)
748+
).not.toThrow();
749+
expect(() => removeBatch([item], StorageScope.Disk)).not.toThrow();
750+
} finally {
751+
Object.defineProperty(globalThis, "localStorage", {
752+
value: originalLocalStorage,
753+
configurable: true,
754+
writable: true,
755+
});
756+
}
757+
});
758+
759+
it("flushes pending secure writes for secure batch set/remove", () => {
760+
const coalesced = createStorageItem({
761+
key: "secure-batch-coalesced",
762+
scope: StorageScope.Secure,
763+
defaultValue: "default",
764+
coalesceSecureWrites: true,
765+
});
766+
const secureBatchItem = createStorageItem({
767+
key: "secure-batch-item",
768+
scope: StorageScope.Secure,
769+
defaultValue: "default",
770+
});
771+
772+
coalesced.set("queued-before-batch");
773+
setBatch([{ item: secureBatchItem, value: "batched" }], StorageScope.Secure);
774+
expect(secureBatchItem.get()).toBe("batched");
775+
776+
coalesced.set("queued-before-remove");
777+
removeBatch([secureBatchItem], StorageScope.Secure);
778+
expect(secureBatchItem.get()).toBe("default");
779+
});
780+
781+
it("validates migration registration version rules", () => {
782+
expect(() => registerMigration(0, () => undefined)).toThrow(
783+
/positive integer/
784+
);
785+
786+
const version = migrationVersionSeed++;
787+
registerMigration(version, () => undefined);
788+
expect(() => registerMigration(version, () => undefined)).toThrow(
789+
/already registered/
790+
);
791+
});
792+
793+
it("treats invalid stored migration versions as zero", () => {
794+
const version = migrationVersionSeed++;
795+
registerMigration(version, () => undefined);
796+
globalThis.localStorage.setItem(MIGRATION_VERSION_KEY, "not-a-number");
797+
798+
expect(migrateToLatest(StorageScope.Disk)).toBe(version);
799+
});
800+
801+
it("flushes secure queue in transactions and records rollback once per key", () => {
802+
const queued = createStorageItem({
803+
key: "secure-tx-queued",
804+
scope: StorageScope.Secure,
805+
defaultValue: "default",
806+
coalesceSecureWrites: true,
807+
});
808+
const item = createStorageItem({
809+
key: "secure-tx-key",
810+
scope: StorageScope.Secure,
811+
defaultValue: "default",
812+
});
813+
814+
queued.set("pending");
815+
816+
runTransaction(StorageScope.Secure, (tx) => {
817+
tx.setRaw("secure-tx-key", serializeWithPrimitiveFastPath("first"));
818+
tx.setRaw("secure-tx-key", serializeWithPrimitiveFastPath("second"));
819+
tx.setItem(item, "committed");
820+
});
821+
822+
expect(item.get()).toBe("committed");
823+
});
824+
825+
it("notifies listeners when _triggerListeners is called", () => {
826+
const item = createStorageItem({
827+
key: "manual-trigger-web",
828+
scope: StorageScope.Disk,
829+
defaultValue: "default",
830+
});
831+
const listenerA = jest.fn();
832+
const listenerB = jest.fn();
833+
834+
item.subscribe(listenerA);
835+
item.subscribe(listenerB);
836+
item._triggerListeners();
837+
838+
expect(listenerA).toHaveBeenCalledTimes(1);
839+
expect(listenerB).toHaveBeenCalledTimes(1);
840+
});
609841
});

0 commit comments

Comments
 (0)