Files
tech-log-frontend/tests/helpers/fake-push-control-repository.ts

229 lines
5.8 KiB
TypeScript

import type {
IndexedDbCompareAndSwapInput,
IndexedDbDeleteInput,
IndexedDbWriteReceipt,
} from "../../src/application/ports/browser-file-storage/indexeddb-port.ts";
import type {
BrowserDataResult,
} from "../../src/application/ports/browser-file-storage/shared.ts";
import type {
PushControlV1,
} from "../../src/contracts/web-push.ts";
import type {
PushControlRepository,
} from "../../src/adapters/web-push/push-association-fence-store.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../src/adapters/browser-file-storage/result.ts";
type StoredControl = Readonly<{
value: unknown;
revision: number;
}>;
type Deferred = Readonly<{
promise: Promise<void>;
resolve(): void;
}>;
export type PausedPushControlWrite = Readonly<{
reached: Promise<void>;
release(): void;
}>;
export class FakePushControlRepository
implements PushControlRepository
{
#stored: StoredControl | null = null;
#closed = false;
#pause:
| Readonly<{
predicate(value: PushControlV1): boolean;
reached: Deferred;
released: Deferred;
}>
| null = null;
#pausedRemove:
| Readonly<{
reached: Deferred;
released: Deferred;
}>
| null = null;
open(
signal?: AbortSignal,
): Promise<BrowserDataResult<void>> {
return Promise.resolve(this.#available(signal, undefined));
}
read(
_key: string,
signal?: AbortSignal,
): Promise<
BrowserDataResult<
Readonly<{ value: PushControlV1; revision: number }> | null
>
> {
const unavailable = this.#unavailable(signal);
if (unavailable) return Promise.resolve(unavailable);
if (!this.#stored) return Promise.resolve(browserDataSuccess(null));
return Promise.resolve(
browserDataSuccess(
Object.freeze({
value: this.#stored.value as PushControlV1,
revision: this.#stored.revision,
}),
),
);
}
async compareAndSwap(
input: IndexedDbCompareAndSwapInput<PushControlV1>,
): Promise<BrowserDataResult<IndexedDbWriteReceipt>> {
const unavailable = this.#unavailable(input.signal);
if (unavailable) return unavailable;
const pause = this.#pause;
if (pause?.predicate(input.value)) {
this.#pause = null;
pause.reached.resolve();
await pause.released.promise;
}
const lateUnavailable = this.#unavailable(input.signal);
if (lateUnavailable) return lateUnavailable;
const currentRevision = this.#stored?.revision ?? null;
if (currentRevision !== input.expectedRevision) {
return browserDataFailure("CONFLICT", "INDEXEDDB_WRITE");
}
const revision = (currentRevision ?? 0) + 1;
this.#stored = Object.freeze({
value: structuredClone(input.value),
revision,
});
return browserDataSuccess(
Object.freeze({
key: input.key,
revision,
replayed: false,
}),
);
}
async remove(
input: IndexedDbDeleteInput,
): Promise<BrowserDataResult<IndexedDbWriteReceipt>> {
const unavailable = this.#unavailable(input.signal);
if (unavailable) return unavailable;
const pause = this.#pausedRemove;
if (pause) {
this.#pausedRemove = null;
pause.reached.resolve();
await pause.released.promise;
}
const lateUnavailable = this.#unavailable(input.signal);
if (lateUnavailable) return lateUnavailable;
if (
!this.#stored ||
this.#stored.revision !== input.expectedRevision
) {
return browserDataFailure("CONFLICT", "INDEXEDDB_WRITE");
}
const revision = input.expectedRevision + 1;
this.#stored = null;
return browserDataSuccess(
Object.freeze({
key: input.key,
revision,
replayed: false,
}),
);
}
close(): void {
this.#closed = true;
}
seedRaw(value: unknown, revision = 1): void {
this.#stored = Object.freeze({ value, revision });
}
pauseNextWrite(
predicate: (value: PushControlV1) => boolean,
): PausedPushControlWrite {
if (this.#pause) {
throw new Error("A push-control write is already paused.");
}
const reached = deferred();
const released = deferred();
this.#pause = Object.freeze({ predicate, reached, released });
return Object.freeze({
reached: reached.promise,
release: released.resolve,
});
}
pauseNextRemove(): PausedPushControlWrite {
if (this.#pausedRemove) {
throw new Error("A push-control remove is already paused.");
}
const reached = deferred();
const released = deferred();
this.#pausedRemove = Object.freeze({ reached, released });
return Object.freeze({
reached: reached.promise,
release: released.resolve,
});
}
#available<Value>(
signal: AbortSignal | undefined,
value: Value,
): BrowserDataResult<Value> {
return (
this.#unavailable(signal) ?? browserDataSuccess(value)
);
}
#unavailable(
signal: AbortSignal | undefined,
): BrowserDataResult<never> | null {
if (signal?.aborted) {
return browserDataFailure("ABORTED", "INDEXEDDB_READ");
}
return this.#closed
? browserDataFailure("UNAVAILABLE", "INDEXEDDB_OPEN", {
retryable: true,
recovery: "REOPEN",
})
: null;
}
}
export function createFakePushControlStoreDependencies(): Readonly<{
repository: FakePushControlRepository;
idempotencyKeyFactory(): string;
}> {
const repository = new FakePushControlRepository();
let sequence = 0;
return Object.freeze({
repository,
idempotencyKeyFactory() {
sequence += 1;
return `push-control-test-${sequence}`;
},
});
}
function deferred(): Deferred {
let resolvePromise: (() => void) | undefined;
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
return Object.freeze({
promise,
resolve() {
resolvePromise?.();
},
});
}