feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ResumableUploadCheckpoint } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
||||
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
||||
import {
|
||||
createIndexedDbResumableUploadCheckpointRuntime,
|
||||
createIndexedDbResumableUploadCheckpointStore,
|
||||
uploadCheckpointDatabaseName,
|
||||
} from "../../src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts";
|
||||
import { MemoryIndexedDbFactory } from "../helpers/memory-indexeddb.ts";
|
||||
|
||||
const scope = Object.freeze({
|
||||
authorityToken: "authority_token_01",
|
||||
namespaceToken: "namespace_token_01",
|
||||
partitionToken: "partition_token_01",
|
||||
});
|
||||
|
||||
function checkpoint(
|
||||
revision: number,
|
||||
overrides: Partial<ResumableUploadCheckpoint> = {},
|
||||
): ResumableUploadCheckpoint {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
revision,
|
||||
state: "ACTIVE",
|
||||
uploadKey: "upload_key_01",
|
||||
requestBindingSha256: "a".repeat(64),
|
||||
fingerprint: {
|
||||
algorithm: "SHA-256-PARTS-V1",
|
||||
digestHex: "b".repeat(64),
|
||||
byteLength: 4,
|
||||
partSizeBytes: 4,
|
||||
partCount: 1,
|
||||
},
|
||||
sessionId: "session_01",
|
||||
sessionExpiresAtEpochMs: 5_000,
|
||||
sessionMaxConcurrency: 1,
|
||||
acceptedParts: [],
|
||||
updatedAtEpochMs: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deletingFactory(
|
||||
memory: MemoryIndexedDbFactory,
|
||||
mode: "SUCCESS" | "BLOCKED",
|
||||
): IDBFactory {
|
||||
return {
|
||||
open: memory.factory.open.bind(memory.factory),
|
||||
cmp: memory.factory.cmp.bind(memory.factory),
|
||||
deleteDatabase: () => {
|
||||
const request = {
|
||||
result: undefined,
|
||||
error: null,
|
||||
transaction: null,
|
||||
source: null,
|
||||
readyState: "pending",
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
onblocked: null,
|
||||
onupgradeneeded: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent: () => true,
|
||||
} as unknown as IDBOpenDBRequest;
|
||||
queueMicrotask(() => {
|
||||
if (mode === "SUCCESS") {
|
||||
request.onsuccess?.(new Event("success"));
|
||||
} else {
|
||||
request.onblocked?.({
|
||||
oldVersion: 1,
|
||||
newVersion: null,
|
||||
} as IDBVersionChangeEvent);
|
||||
}
|
||||
});
|
||||
return request;
|
||||
},
|
||||
databases: async () => [],
|
||||
} as IDBFactory;
|
||||
}
|
||||
|
||||
describe("IndexedDB resumable upload checkpoint", () => {
|
||||
it("length-prefixes scope tuples so delimiter placement cannot collide", () => {
|
||||
const first = uploadCheckpointDatabaseName({
|
||||
authorityToken: "aaaaaaaa-bbbbbbbb",
|
||||
namespaceToken: "cccccccc",
|
||||
partitionToken: "dddddddd",
|
||||
});
|
||||
const second = uploadCheckpointDatabaseName({
|
||||
authorityToken: "aaaaaaaa",
|
||||
namespaceToken: "bbbbbbbb-cccccccc",
|
||||
partitionToken: "dddddddd",
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first).toContain("17:aaaaaaaa-bbbbbbbb");
|
||||
expect(second).toContain("8:aaaaaaaa");
|
||||
});
|
||||
|
||||
it("commits CAS only at transaction completion and rejects stale revisions", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const store = createIndexedDbResumableUploadCheckpointStore({
|
||||
scope,
|
||||
factory: memory.factory,
|
||||
});
|
||||
const first = checkpoint(1);
|
||||
expect(
|
||||
await store.compareAndSwap({
|
||||
expectedRevision: null,
|
||||
checkpoint: first,
|
||||
}),
|
||||
).toEqual({ ok: true, value: first });
|
||||
expect(await store.read(first.uploadKey)).toEqual({
|
||||
ok: true,
|
||||
value: first,
|
||||
});
|
||||
|
||||
memory.failNextWriteCommit(
|
||||
new DOMException("commit failed", "UnknownError"),
|
||||
);
|
||||
const failed = await store.compareAndSwap({
|
||||
expectedRevision: 1,
|
||||
checkpoint: checkpoint(2, { state: "ABORT_PENDING" }),
|
||||
});
|
||||
expect(failed).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE" },
|
||||
});
|
||||
expect(await store.read(first.uploadKey)).toEqual({
|
||||
ok: true,
|
||||
value: first,
|
||||
});
|
||||
|
||||
expect(
|
||||
await store.compareAndSwap({
|
||||
expectedRevision: 2,
|
||||
checkpoint: checkpoint(3),
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "CONFLICT", recovery: "RECONCILE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown persisted fields so URLs and credentials cannot enter a checkpoint", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const store = createIndexedDbResumableUploadCheckpointStore({
|
||||
scope,
|
||||
factory: memory.factory,
|
||||
});
|
||||
const smuggled = {
|
||||
...checkpoint(1),
|
||||
signedUrl: "https://object.invalid/secret?signature=value",
|
||||
} as unknown as ResumableUploadCheckpoint;
|
||||
expect(
|
||||
await store.compareAndSwap({
|
||||
expectedRevision: null,
|
||||
checkpoint: smuggled,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
expect(await store.read("upload_key_01")).toEqual({
|
||||
ok: true,
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the bound partition before successful lifecycle deletion", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
||||
scope,
|
||||
factory: deletingFactory(memory, "SUCCESS"),
|
||||
blockedTimeoutMs: 10,
|
||||
});
|
||||
expect(
|
||||
await runtime.store.compareAndSwap({
|
||||
expectedRevision: null,
|
||||
checkpoint: checkpoint(1),
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(await runtime.admin.deletePartition()).toEqual({
|
||||
ok: true,
|
||||
value: { state: "DELETED" },
|
||||
});
|
||||
expect(await runtime.store.read("upload_key_01")).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", recovery: "RESUME" },
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds a partition deletion blocked by another browser context", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
||||
scope,
|
||||
factory: deletingFactory(memory, "BLOCKED"),
|
||||
blockedTimeoutMs: 1,
|
||||
});
|
||||
expect(await runtime.admin.deletePartition()).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "BLOCKED",
|
||||
retryable: true,
|
||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("never reports a false abort after irreversible deleteDatabase dispatch", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
||||
scope,
|
||||
factory: deletingFactory(memory, "SUCCESS"),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const deletion = runtime.admin.deletePartition(controller.signal);
|
||||
controller.abort();
|
||||
expect(await deletion).toEqual({
|
||||
ok: true,
|
||||
value: { state: "DELETED" },
|
||||
});
|
||||
|
||||
const preAborted = new AbortController();
|
||||
preAborted.abort();
|
||||
const second = createIndexedDbResumableUploadCheckpointRuntime({
|
||||
scope: { ...scope, partitionToken: "partition_token_02" },
|
||||
factory: deletingFactory(new MemoryIndexedDbFactory(), "SUCCESS"),
|
||||
});
|
||||
expect(
|
||||
await second.admin.deletePartition(preAborted.signal),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user