Initial Commit
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import type { CacheSetOptions, CacheStats } from "@suaveplan/cache";
|
||||
import { AbstractCache } from "@suaveplan/cache";
|
||||
|
||||
/**
|
||||
* Real in-memory `AbstractCache<Uint8Array>` subclass used by the unit
|
||||
* tests. NOT a mock — it implements every abstract method against a
|
||||
* backing `Map`. The `failNext(op, err)` helper injects a deterministic
|
||||
* rejection for the next call to that op so the wrapper's
|
||||
* cache-failure-swallow paths are exercised against real throwing
|
||||
* behaviour.
|
||||
*
|
||||
* The class deliberately omits `keys()` by default; the
|
||||
* `KeysCapableFakeCache` subclass adds it. This lets the wrapper's
|
||||
* `clear()` feature-detection be tested against both shapes without a
|
||||
* mock.
|
||||
*/
|
||||
export class FakeCache extends AbstractCache<Uint8Array> {
|
||||
#data = new Map<string, Uint8Array>();
|
||||
public getCalls: string[] = [];
|
||||
public setCalls: { key: string; value: Uint8Array }[] = [];
|
||||
public deleteCalls: string[] = [];
|
||||
public clearCalls = 0;
|
||||
public hasCalls: string[] = [];
|
||||
|
||||
#failNext: Partial<
|
||||
Record<"get" | "set" | "delete" | "clear" | "has", Error>
|
||||
> = {};
|
||||
|
||||
public failNext(
|
||||
op: "get" | "set" | "delete" | "clear" | "has",
|
||||
err: Error
|
||||
): void {
|
||||
this.#failNext[op] = err;
|
||||
}
|
||||
|
||||
override async get(key: string): Promise<Uint8Array | undefined> {
|
||||
this.getCalls.push(key);
|
||||
const fail = this.#failNext.get;
|
||||
if (fail !== undefined) {
|
||||
this.#failNext.get = undefined;
|
||||
throw fail;
|
||||
}
|
||||
return this.#data.get(key);
|
||||
}
|
||||
|
||||
override async set(
|
||||
key: string,
|
||||
value: Uint8Array,
|
||||
_options?: CacheSetOptions
|
||||
): Promise<void> {
|
||||
this.setCalls.push({ key, value });
|
||||
const fail = this.#failNext.set;
|
||||
if (fail !== undefined) {
|
||||
this.#failNext.set = undefined;
|
||||
throw fail;
|
||||
}
|
||||
this.#data.set(key, value);
|
||||
}
|
||||
|
||||
override async delete(key: string): Promise<boolean> {
|
||||
this.deleteCalls.push(key);
|
||||
const fail = this.#failNext.delete;
|
||||
if (fail !== undefined) {
|
||||
this.#failNext.delete = undefined;
|
||||
throw fail;
|
||||
}
|
||||
return this.#data.delete(key);
|
||||
}
|
||||
|
||||
override async clear(): Promise<void> {
|
||||
this.clearCalls += 1;
|
||||
const fail = this.#failNext.clear;
|
||||
if (fail !== undefined) {
|
||||
this.#failNext.clear = undefined;
|
||||
throw fail;
|
||||
}
|
||||
this.#data.clear();
|
||||
}
|
||||
|
||||
override async has(key: string): Promise<boolean> {
|
||||
this.hasCalls.push(key);
|
||||
const fail = this.#failNext.has;
|
||||
if (fail !== undefined) {
|
||||
this.#failNext.has = undefined;
|
||||
throw fail;
|
||||
}
|
||||
return this.#data.has(key);
|
||||
}
|
||||
|
||||
override getStats(): CacheStats {
|
||||
return {
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
size: this.#data.size,
|
||||
hitRate: 0,
|
||||
evictions: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Test-only direct access. */
|
||||
public _peek(key: string): Uint8Array | undefined {
|
||||
return this.#data.get(key);
|
||||
}
|
||||
|
||||
/** Test-only direct seed. */
|
||||
public _seed(key: string, value: Uint8Array): void {
|
||||
this.#data.set(key, value);
|
||||
}
|
||||
|
||||
public get _data(): ReadonlyMap<string, Uint8Array> {
|
||||
return this.#data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `FakeCache` extension that exposes a synchronous `keys()` iterator
|
||||
* — the exact shape `CachedStorage.clear()` feature-detects.
|
||||
*/
|
||||
export class KeysCapableFakeCache extends FakeCache {
|
||||
public keysCalls = 0;
|
||||
|
||||
public *keys(): IterableIterator<string> {
|
||||
this.keysCalls += 1;
|
||||
for (const k of this._data.keys()) yield k;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `FakeCache` extension exposing an async `keys()` iterator —
|
||||
* representative of remote cache backends that stream keys.
|
||||
*/
|
||||
export class AsyncKeysCapableFakeCache extends FakeCache {
|
||||
public keysCalls = 0;
|
||||
|
||||
public async *keys(): AsyncIterableIterator<string> {
|
||||
this.keysCalls += 1;
|
||||
for (const k of this._data.keys()) {
|
||||
yield k;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
AbstractStorage,
|
||||
STORAGE_CODES,
|
||||
StorageError,
|
||||
type StorageListOptions,
|
||||
type StorageListResult,
|
||||
type StorageMetadata,
|
||||
type StorageResult,
|
||||
} from "@suaveplan/storage";
|
||||
|
||||
/**
|
||||
* Real in-memory `AbstractStorage` subclass used by the unit tests. NOT
|
||||
* a mock — it implements every abstract method against a backing
|
||||
* `Map<string, Uint8Array>`. The helpers `setFailOn` / `setReturn`
|
||||
* inject deterministic failures or value overrides for one upcoming
|
||||
* call (e.g. force `set("x", _)` to reject with a foreign throwable so
|
||||
* the wrapper's "failed set does not invalidate" path is exercised).
|
||||
*/
|
||||
export class FakeStorage extends AbstractStorage {
|
||||
#data = new Map<string, Uint8Array>();
|
||||
public getCalls: { key: string; signal?: AbortSignal | undefined }[] = [];
|
||||
public setCalls: {
|
||||
key: string;
|
||||
value: Uint8Array;
|
||||
signal?: AbortSignal | undefined;
|
||||
}[] = [];
|
||||
public setPartialCalls: {
|
||||
key: string;
|
||||
offset: number;
|
||||
bytes: Uint8Array;
|
||||
}[] = [];
|
||||
public deleteCalls: { key: string }[] = [];
|
||||
public clearCalls = 0;
|
||||
public existsCalls: { key: string }[] = [];
|
||||
public listCalls: { options?: StorageListOptions | undefined }[] = [];
|
||||
public getMetadataCalls: { key: string }[] = [];
|
||||
public getWithMetadataCalls: { key: string }[] = [];
|
||||
public getSizeCalls = 0;
|
||||
public getItemSizeCalls: { key: string }[] = [];
|
||||
public getPartialCalls: {
|
||||
key: string;
|
||||
offset: number;
|
||||
length: number;
|
||||
}[] = [];
|
||||
public createReadStreamCalls: { key: string }[] = [];
|
||||
public createWriteStreamCalls: { key: string }[] = [];
|
||||
|
||||
#failNext: Partial<
|
||||
Record<"get" | "set" | "setPartial" | "delete" | "clear", Error>
|
||||
> = {};
|
||||
|
||||
#returnNext: { get?: Uint8Array | undefined } = {};
|
||||
|
||||
public failNext(
|
||||
op: "get" | "set" | "setPartial" | "delete" | "clear",
|
||||
err: Error
|
||||
): void {
|
||||
this.#failNext[op] = err;
|
||||
}
|
||||
|
||||
public returnNextGet(value: Uint8Array | undefined): void {
|
||||
this.#returnNext.get = value;
|
||||
}
|
||||
|
||||
public seed(key: string, value: Uint8Array): void {
|
||||
this.#data.set(key, value);
|
||||
}
|
||||
|
||||
async get(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Uint8Array | undefined> {
|
||||
this.getCalls.push({ key, signal });
|
||||
const fail = this.#failNext.get;
|
||||
if (fail !== undefined) {
|
||||
this.#failNext.get = undefined;
|
||||
throw fail;
|
||||
}
|
||||
if (signal?.aborted === true) {
|
||||
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
|
||||
}
|
||||
if ("get" in this.#returnNext) {
|
||||
const override = this.#returnNext.get;
|
||||
delete this.#returnNext.get;
|
||||
return override;
|
||||
}
|
||||
return this.#data.get(key);
|
||||
}
|
||||
|
||||
async set(
|
||||
key: string,
|
||||
value: Uint8Array,
|
||||
_options?: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
this.setCalls.push({ key, value, signal });
|
||||
const fail = this.#failNext.set;
|
||||
if (fail !== undefined) {
|
||||
this.#failNext.set = undefined;
|
||||
throw fail;
|
||||
}
|
||||
if (signal?.aborted === true) {
|
||||
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
|
||||
}
|
||||
this.#data.set(key, value);
|
||||
}
|
||||
|
||||
async setPartial(
|
||||
key: string,
|
||||
offset: number,
|
||||
bytes: Uint8Array,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
this.setPartialCalls.push({ key, offset, bytes });
|
||||
const fail = this.#failNext.setPartial;
|
||||
if (fail !== undefined) {
|
||||
this.#failNext.setPartial = undefined;
|
||||
throw fail;
|
||||
}
|
||||
if (signal?.aborted === true) {
|
||||
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
|
||||
}
|
||||
await this.defaultSetPartial(key, offset, bytes, signal);
|
||||
}
|
||||
|
||||
async delete(key: string, signal?: AbortSignal): Promise<boolean> {
|
||||
this.deleteCalls.push({ key });
|
||||
const fail = this.#failNext.delete;
|
||||
if (fail !== undefined) {
|
||||
this.#failNext.delete = undefined;
|
||||
throw fail;
|
||||
}
|
||||
if (signal?.aborted === true) {
|
||||
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
|
||||
}
|
||||
return this.#data.delete(key);
|
||||
}
|
||||
|
||||
async clear(signal?: AbortSignal): Promise<void> {
|
||||
this.clearCalls += 1;
|
||||
const fail = this.#failNext.clear;
|
||||
if (fail !== undefined) {
|
||||
this.#failNext.clear = undefined;
|
||||
throw fail;
|
||||
}
|
||||
if (signal?.aborted === true) {
|
||||
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
|
||||
}
|
||||
this.#data.clear();
|
||||
}
|
||||
|
||||
async exists(key: string, _signal?: AbortSignal): Promise<boolean> {
|
||||
this.existsCalls.push({ key });
|
||||
return this.#data.has(key);
|
||||
}
|
||||
|
||||
async list(
|
||||
options?: StorageListOptions,
|
||||
_signal?: AbortSignal
|
||||
): Promise<StorageListResult> {
|
||||
this.listCalls.push({ options });
|
||||
const prefix = options?.prefix ?? "";
|
||||
const keys = [...this.#data.keys()].filter((k) => k.startsWith(prefix));
|
||||
return { keys, total: keys.length, hasMore: false };
|
||||
}
|
||||
|
||||
async getMetadata(
|
||||
key: string,
|
||||
_signal?: AbortSignal
|
||||
): Promise<StorageMetadata | null> {
|
||||
this.getMetadataCalls.push({ key });
|
||||
const bytes = this.#data.get(key);
|
||||
if (bytes === undefined) return null;
|
||||
return {
|
||||
size: bytes.byteLength,
|
||||
createdAt: 0,
|
||||
modifiedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getWithMetadata(
|
||||
key: string,
|
||||
_signal?: AbortSignal
|
||||
): Promise<StorageResult<Uint8Array> | null> {
|
||||
this.getWithMetadataCalls.push({ key });
|
||||
const bytes = this.#data.get(key);
|
||||
if (bytes === undefined) return null;
|
||||
return {
|
||||
value: bytes,
|
||||
metadata: {
|
||||
size: bytes.byteLength,
|
||||
createdAt: 0,
|
||||
modifiedAt: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getSize(_signal?: AbortSignal): Promise<number> {
|
||||
this.getSizeCalls += 1;
|
||||
let total = 0;
|
||||
for (const v of this.#data.values()) total += v.byteLength;
|
||||
return total;
|
||||
}
|
||||
|
||||
async getItemSize(
|
||||
key: string,
|
||||
_signal?: AbortSignal
|
||||
): Promise<number | null> {
|
||||
this.getItemSizeCalls.push({ key });
|
||||
const bytes = this.#data.get(key);
|
||||
return bytes === undefined ? null : bytes.byteLength;
|
||||
}
|
||||
|
||||
async getPartial(
|
||||
key: string,
|
||||
offset: number,
|
||||
length: number,
|
||||
_signal?: AbortSignal
|
||||
): Promise<Uint8Array | null> {
|
||||
this.getPartialCalls.push({ key, offset, length });
|
||||
const bytes = this.#data.get(key);
|
||||
if (bytes === undefined) return null;
|
||||
return bytes.subarray(offset, offset + length);
|
||||
}
|
||||
|
||||
createReadStream(
|
||||
key: string,
|
||||
_signal?: AbortSignal
|
||||
): ReadableStream<Uint8Array> {
|
||||
this.createReadStreamCalls.push({ key });
|
||||
const bytes = this.#data.get(key);
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
if (bytes !== undefined) controller.enqueue(bytes);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
createWriteStream(
|
||||
key: string,
|
||||
_signal?: AbortSignal
|
||||
): WritableStream<Uint8Array> {
|
||||
this.createWriteStreamCalls.push({ key });
|
||||
const chunks: Uint8Array[] = [];
|
||||
const data = this.#data;
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
chunks.push(chunk);
|
||||
},
|
||||
close() {
|
||||
let total = 0;
|
||||
for (const c of chunks) total += c.byteLength;
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
merged.set(c, offset);
|
||||
offset += c.byteLength;
|
||||
}
|
||||
data.set(key, merged);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Polyfills for test environment — none required beyond what
|
||||
// `@suaveplan/testing/polyfills` already installs. Node 18+ provides
|
||||
// `URL`, `AbortController`, `ReadableStream`, `WritableStream`, and
|
||||
// `Response` natively. File exists so vitest has a stable additional
|
||||
// setup entry alongside the package-local fixture imports.
|
||||
export {};
|
||||
Reference in New Issue
Block a user