113 lines
4.8 KiB
TypeScript
113 lines
4.8 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
CompositeBlobStorage,
|
|
LocalStorageBlobStorage,
|
|
MemoryBlobStorage,
|
|
composeStorageTransforms,
|
|
createAesGcmTransform,
|
|
createMsgPackTransform,
|
|
type StorageTransform,
|
|
} from "../src/index.js";
|
|
|
|
interface ExampleValue {
|
|
readonly name: string;
|
|
readonly count: bigint;
|
|
readonly createdAt: Date;
|
|
}
|
|
|
|
describe("storage transforms", () => {
|
|
it("round trips nested MessagePack values", async () => {
|
|
const transform = createMsgPackTransform<ExampleValue>();
|
|
const value = { name: "test", count: 9n, createdAt: new Date("2026-01-01T00:00:00.000Z") };
|
|
expect(await transform.decode(await transform.encode(value))).toEqual(value);
|
|
});
|
|
|
|
it("composes randomized AES-GCM encryption and rejects modified ciphertext", async () => {
|
|
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
|
|
const transform = composeStorageTransforms(
|
|
createMsgPackTransform<ExampleValue>(),
|
|
createAesGcmTransform(key),
|
|
);
|
|
const value = { name: "secret", count: 3n, createdAt: new Date("2026-02-01T00:00:00.000Z") };
|
|
const first = await transform.encode(value);
|
|
const second = await transform.encode(value);
|
|
expect(first).not.toEqual(second);
|
|
expect(await transform.decode(first)).toEqual(value);
|
|
const modified = first.slice();
|
|
modified[modified.length - 1] ^= 1;
|
|
await expect(transform.decode(modified)).rejects.toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("MemoryBlobStorage", () => {
|
|
it("stores logical values and emits added exactly once after persistence", async () => {
|
|
const encode = vi.fn(async (value: { value: number }) => new Uint8Array([value.value]));
|
|
const transform: StorageTransform<{ value: number }> = {
|
|
encode,
|
|
async decode(bytes) { return { value: bytes[0] ?? 0 }; },
|
|
};
|
|
const storage = new MemoryBlobStorage({ transform });
|
|
const events: unknown[] = [];
|
|
storage.on("added", (event) => events.push(event));
|
|
|
|
await storage.set("item/a", { value: 1 });
|
|
await storage.set("item/a", { value: 99 });
|
|
|
|
expect(await storage.get("item/a")).toEqual({ value: 1 });
|
|
expect(await storage.keys("item/")).toEqual(["item/a"]);
|
|
expect(await storage.has("missing")).toBe(false);
|
|
expect(encode).toHaveBeenCalledTimes(1);
|
|
expect(events).toEqual([{ id: "item/a", data: { value: 1 }, source: "local" }]);
|
|
});
|
|
|
|
it("isolates listener exceptions from successful persistence", async () => {
|
|
const storage = new MemoryBlobStorage<{ value: number }>();
|
|
const errors: unknown[] = [];
|
|
storage.on("added", () => { throw new Error("listener failed"); });
|
|
storage.on("error", (error) => errors.push(error));
|
|
await expect(storage.set("id", { value: 1 })).resolves.toBeUndefined();
|
|
expect(await storage.get("id")).toEqual({ value: 1 });
|
|
expect(errors).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("LocalStorageBlobStorage", () => {
|
|
it("stores transformed values under a namespace and reopens them", async () => {
|
|
const physical = new FakeStorage();
|
|
const first = new LocalStorageBlobStorage<{ value: string }>("test", { storage: physical });
|
|
await first.set("a", { value: "persisted" });
|
|
await first.close();
|
|
const reopened = new LocalStorageBlobStorage<{ value: string }>("test", { storage: physical });
|
|
expect(await reopened.get("a")).toEqual({ value: "persisted" });
|
|
expect(await reopened.keys()).toEqual(["a"]);
|
|
});
|
|
});
|
|
|
|
describe("CompositeBlobStorage", () => {
|
|
it("synchronizes the logical ID union and imports remote observations once", async () => {
|
|
const primary = new MemoryBlobStorage<{ value: number }>();
|
|
const replica = new MemoryBlobStorage<{ value: number }>();
|
|
await primary.set("primary", { value: 1 });
|
|
await replica.set("replica", { value: 2 });
|
|
const composite = new CompositeBlobStorage({ primary, replicas: [replica] });
|
|
await composite.synchronize();
|
|
expect(await primary.get("replica")).toEqual({ value: 2 });
|
|
expect(await replica.get("primary")).toEqual({ value: 1 });
|
|
|
|
const added = composite.waitFor("added", (event) => event.id === "remote");
|
|
await replica.set("remote", { value: 3 });
|
|
await expect(added).resolves.toMatchObject({ id: "remote", data: { value: 3 } });
|
|
expect(await primary.get("remote")).toEqual({ value: 3 });
|
|
});
|
|
});
|
|
|
|
class FakeStorage implements Storage {
|
|
readonly #values = new Map<string, string>();
|
|
get length(): number { return this.#values.size; }
|
|
clear(): void { this.#values.clear(); }
|
|
getItem(key: string): string | null { return this.#values.get(key) ?? null; }
|
|
key(index: number): string | null { return [...this.#values.keys()][index] ?? null; }
|
|
removeItem(key: string): void { this.#values.delete(key); }
|
|
setItem(key: string, value: string): void { this.#values.set(key, value); }
|
|
}
|