Stage 5 gaps: real QuotaExceededError injection (P3), openDB oldVersion migration-seam test, wrapper oldVersion from IDBVersionChangeEvent
This commit is contained in:
parent
99419880a7
commit
8a1171fa41
2 changed files with 96 additions and 11 deletions
|
|
@ -10,11 +10,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|||
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
|
||||
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||
import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange";
|
||||
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||
import FDBObjectStore from "fake-indexeddb/lib/FDBObjectStore";
|
||||
|
||||
// polyfill globals for Node environment
|
||||
const g = globalThis as unknown as Record<string, unknown>;
|
||||
g.indexedDB = new FDBFactory() as unknown;
|
||||
g.IDBKeyRange = FDBKeyRange as unknown;
|
||||
g.IDBObjectStore = FDBObjectStore as unknown;
|
||||
|
||||
// dynamic imports after polyfill — wrapper reads global indexedDB at call time
|
||||
import { MAX_RECORD_BYTES, isQuotaError, checkRecordSize } from "../../src/platform/idb/errors.js";
|
||||
|
|
@ -270,11 +273,8 @@ describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => {
|
|||
},
|
||||
appVersionAtActivation: "1.0.0",
|
||||
});
|
||||
// attempt staging into inactive slot B but inject quota error via mocked put
|
||||
// Simulate by directly testing isQuotaError path and ensuring active still readable
|
||||
const activeBefore = await readSystemMeta(sys);
|
||||
expect(activeBefore.activeSlot).toBe("A");
|
||||
// No actual quota error from fake-indexeddb, but verify active dataset still complete
|
||||
expect(await readSlotFile(slotA, "emergency")).toEqual({ section: "emergency", v: 1 });
|
||||
// B remains empty — staging interrupted keeps active
|
||||
expect(await readSlotFile(slotB, "emergency")).toBeUndefined();
|
||||
|
|
@ -282,6 +282,93 @@ describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => {
|
|||
slotB.close();
|
||||
sys.close();
|
||||
});
|
||||
|
||||
it("P3 quota INJECTION — put() throwing QuotaExceededError rejects the write and keeps active slot intact", async () => {
|
||||
// Real injection: patch IDBObjectStore.prototype.put so writes of the
|
||||
// staged blob throw a genuine QuotaExceededError-shaped failure.
|
||||
const slotA = await openSlotDB("A");
|
||||
const slotB = await openSlotDB("B");
|
||||
const sys = await openSystemDB();
|
||||
await writeSlotFile(slotA, "emergency", {
|
||||
bytes: 100,
|
||||
sha256: "a".repeat(64),
|
||||
json: { section: "emergency", v: 1 },
|
||||
});
|
||||
await writeSystemMeta(sys, {
|
||||
...INITIAL_SYSTEM_META,
|
||||
activeSlot: "A",
|
||||
activeEdition: "lumen-2026",
|
||||
activePackageVersion: 1,
|
||||
});
|
||||
|
||||
const proto = (globalThis as unknown as { IDBObjectStore: { prototype: IDBObjectStore } })
|
||||
.IDBObjectStore.prototype;
|
||||
const originalPut = proto.put;
|
||||
let injected = 0;
|
||||
// Deliberate prototype patch — `this` scoping is the point of the shim.
|
||||
// eslint-disable-next-line
|
||||
proto.put = function (this: IDBObjectStore, value: unknown, key?: IDBValidKey) {
|
||||
const rec = value as { bytes?: number } | null;
|
||||
if (rec && typeof rec.bytes === "number" && rec.bytes >= 1024) {
|
||||
injected++;
|
||||
throw new DOMException("injected quota", "QuotaExceededError");
|
||||
}
|
||||
return originalPut.call(this, value, key);
|
||||
} as typeof proto.put;
|
||||
|
||||
try {
|
||||
const big = new Blob([new Uint8Array(4096)]);
|
||||
await expect(
|
||||
writeSlotAsset(slotB, "map-base-overview", {
|
||||
bytes: big.size,
|
||||
sha256: "b".repeat(64),
|
||||
blob: big,
|
||||
}),
|
||||
).rejects.toSatisfy(isQuotaError);
|
||||
expect(injected).toBeGreaterThan(0);
|
||||
// P3 invariant: active dataset fully readable, inactive slot empty.
|
||||
expect(await readSlotFile(slotA, "emergency")).toEqual({ section: "emergency", v: 1 });
|
||||
const meta = await readSystemMeta(sys);
|
||||
expect(meta.activeSlot).toBe("A");
|
||||
expect(await readSlotAsset(slotB, "map-base-overview")).toBeUndefined();
|
||||
} finally {
|
||||
proto.put = originalPut;
|
||||
}
|
||||
slotA.close();
|
||||
slotB.close();
|
||||
sys.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("migration seam — openDB versionchange (lumen-user migrations ride here)", () => {
|
||||
beforeEach(cleanAll);
|
||||
afterEach(cleanAll);
|
||||
|
||||
it("openDB passes authoritative oldVersion to onUpgrade across versions", async () => {
|
||||
// The seam lumen-user migrations will ride on: reopening at a higher
|
||||
// version must report the PRE-upgrade version (not the new one) and run
|
||||
// inside the versionchange txn so data from prior versions is preserved.
|
||||
const name = "lumen-test-migrate";
|
||||
const db1 = await openDB(name, 1, (db) => {
|
||||
db.createObjectStore("prefs");
|
||||
});
|
||||
await idbPut(db1, "prefs", { theme: "amber" }, "prefs");
|
||||
db1.close();
|
||||
|
||||
const seen: number[] = [];
|
||||
const db2 = await openDB(name, 2, (db, oldVersion) => {
|
||||
seen.push(oldVersion);
|
||||
if (oldVersion < 2 && !db.objectStoreNames.contains("favs")) {
|
||||
db.createObjectStore("favs");
|
||||
}
|
||||
});
|
||||
expect(seen).toEqual([1]);
|
||||
// prior-version data intact, new store usable
|
||||
expect(await idbGet(db2, "prefs", "prefs")).toEqual({ theme: "amber" });
|
||||
await idbPut(db2, "favs", { eventId: "e1" }, "e1");
|
||||
db2.close();
|
||||
await deleteDB(name);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system-meta store — P2 single-txn activation + F-2/F-3", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue