@firebase-bridge/storage-mock
v0.0.2
Published
In-memory Cloud Storage mock and trigger harness for Firebase Bridge.
Downloads
218
Readme
@firebase-bridge/storage-mock
Deterministic in-memory Cloud Storage mock and Firebase Storage trigger harness for backend tests. Purpose-built for fast unit and integration tests without a Firebase Emulator, network calls, or deploy loop.
What it is
@firebase-bridge/storage-mock implements the @firebase-bridge/cloud-storage abstraction with deterministic in-memory storage. Tests can write, read, list, update metadata, delete objects, create mock signed read URLs, inspect state, inject failures, and drive Firebase Cloud Storage trigger handlers in-process.
Unlike @firebase-bridge/firestore-admin, this package does not patch the Firebase SDK. Production code should depend on @firebase-bridge/cloud-storage or your own platform facade, then tests can substitute this mock.
When to use it
- Unit and integration tests for backend code that reads or writes Cloud Storage objects.
- CI where the Storage Emulator is slow, unavailable, or unnecessary.
- Tests that need deterministic metadata, generation values, timestamps, operation logs, and reset behavior.
- Trigger tests for Firebase Functions v1/v2 Storage handlers without deploying functions.
Install
# npm
npm i -D @firebase-bridge/storage-mock @firebase-bridge/cloud-storage firebase-functions
# pnpm
pnpm add -D @firebase-bridge/storage-mock @firebase-bridge/cloud-storage firebase-functions
# yarn
yarn add -D @firebase-bridge/storage-mock @firebase-bridge/cloud-storage firebase-functionsPeer deps:
firebase-functionsfor trigger registration. Node: 18+ recommended. TypeScript: strict mode recommended.
Quick start
import { StorageController } from '@firebase-bridge/storage-mock';
describe('storage-backed import', () => {
const storage = new StorageController({
defaultBucket: 'imports.test',
now: () => Date.parse('2026-01-01T00:00:00.000Z'),
});
const bucket = storage.service().bucket();
afterEach(() => {
storage.resetAll();
storage.clearOperationLog();
});
it('writes and reads object text', async () => {
const write = await bucket.writeText('imports/a.csv', 'id,name\n1,Ada\n', {
metadata: {
contentType: 'text/csv',
customMetadata: { importId: 'imp-001' },
},
precondition: { type: 'does-not-exist' },
});
expect(write.metadata.generation).toBe('1');
expect(write.metadata.metageneration).toBe('1');
expect(await bucket.readText('imports/a.csv')).toContain('Ada');
expect(storage.getObjectText('imports.test', 'imports/a.csv')).toContain('Ada');
});
});Production wiring pattern
Use @firebase-bridge/cloud-storage in production code, then inject the mock service in tests:
import type { CloudStorageService } from '@firebase-bridge/cloud-storage';
export async function readManifest(storage: CloudStorageService) {
const bucket = storage.bucket('imports.test');
return JSON.parse(await bucket.readText('manifest.json'));
}import { StorageController } from '@firebase-bridge/storage-mock';
const ctrl = new StorageController({ defaultBucket: 'imports.test' });
ctrl.seedObject('imports.test', 'manifest.json', JSON.stringify({ ok: true }));
await expect(readManifest(ctrl.service())).resolves.toEqual({ ok: true });Core concepts
class StorageController
Top-level test controller that owns isolated in-memory bucket state.
new StorageController({ defaultBucket?, projectId?, location?, now?, signedUrlSigner? })reset(bucket),resetAll()delete(bucket),deleteAll()
Use one StorageController per suite when you want fast resets. Create separate controllers when suites must not share any bucket state.
- Identity/config:
defaultBucket,projectId,location,epoch - Service:
service(): CloudStorageService - State controls:
reset(),resetAll(),delete(),deleteAll() - Seeding/inspection:
seedObject(),getObject(),getObjectText(),hasObject(),listObjects(),deleteObject() - Observability:
getOperationLog(),clearOperationLog(),onObjectChange(),watchLifecycle() - Test behavior:
failNext(),setClock()
Cloud Storage operations
The service implements the @firebase-bridge/cloud-storage bucket/object API:
exists()read()/readText()write()/writeText()delete()getMetadata()/setMetadata()list()createSignedReadUrl()
Bucket-level and object-level facades share the same behavior:
const bucket = ctrl.service().bucket('uploads.test');
const object = bucket.object('avatars/alice.png');
await object.write(new Uint8Array([1, 2, 3]), {
metadata: { contentType: 'image/png' },
});
const metadata = await object.setMetadata({
cacheControl: 'private, max-age=60',
});
expect(metadata.metageneration).toBe('2');Metadata, generations, and preconditions
Object metadata is immutable when returned from the mock. Each object has:
generation: increments when object data is written.metageneration: starts at1and increments when metadata changes.createdAt/updatedAt: derived from the mock clock.customMetadata: frozen user metadata.md5Hash,crc32c, andetag: deterministic test values.
Mutation preconditions mirror the abstraction:
await bucket.writeText('file.txt', 'first', {
precondition: { type: 'does-not-exist' },
});
const meta = await bucket.getMetadata('file.txt');
await bucket.writeText('file.txt', 'second', {
precondition: {
type: 'generation-match',
generation: meta.generation,
},
});Supported precondition types:
{ type: 'none' }{ type: 'does-not-exist' }{ type: 'generation-match', generation }{ type: 'metageneration-match', metageneration }{ type: 'generation-and-metageneration-match', generation, metageneration }
Failures reject with CloudStorageError from @firebase-bridge/cloud-storage.
Listing and pagination
list() returns deterministic path-sorted metadata and uses numeric page tokens:
await bucket.writeText('a/1.txt', 'one');
await bucket.writeText('a/2.txt', 'two');
const page1 = await bucket.list({ prefix: 'a/', pageSize: 1 });
expect(page1.objects.map((m) => m.path)).toEqual(['a/1.txt']);
const page2 = await bucket.list({
prefix: 'a/',
pageSize: 1,
pageToken: page1.nextPageToken,
});
expect(page2.objects.map((m) => m.path)).toEqual(['a/2.txt']);Test controls
Seed objects directly when setup should not exercise application write paths:
ctrl.seedObject('uploads.test', 'seed.txt', 'seeded', {
contentType: 'text/plain',
});
expect(ctrl.hasObject('uploads.test', 'seed.txt')).toBe(true);
expect(ctrl.getObjectText('uploads.test', 'seed.txt')).toBe('seeded');
expect(ctrl.listObjects('uploads.test').map((o) => o.path)).toEqual(['seed.txt']);reset(bucket) and resetAll() clear objects but keep bucket/controller lifecycles alive. delete(bucket) and deleteAll() remove buckets and advance controller epochs. Trigger orchestrators use epochs to ignore stale events captured before a reset.
Operation logs and failure injection
Every service/test-control operation records an operation log entry. Use failNext() to force a matching operation to fail once:
ctrl.failNext({
operation: 'read',
bucketId: 'uploads.test',
path: 'seed.txt',
code: 'storage/unavailable',
});
await expect(bucket.read('seed.txt')).rejects.toMatchObject({
code: 'storage/unavailable',
});
expect(ctrl.getOperationLog()).toContainEqual(
expect.objectContaining({
operation: 'read',
success: false,
errorCode: 'storage/unavailable',
})
);Signed read URLs
The default signer returns deterministic local URLs:
const signed = await bucket.createSignedReadUrl('seed.txt', {
expiresAt: new Date('2026-01-02T00:00:00.000Z'),
});
expect(signed.url).toBe(
'https://storage-mock.local/uploads.test/seed.txt?expires=1767312000000'
);These are not Google Cloud Storage signed URLs. They are local test artifacts. To model application-specific URL formats, pass signedUrlSigner to new StorageController():
const ctrl = new StorageController({
signedUrlSigner: ({ bucketId, path, options }) => ({
url: `mock://${bucketId}/${path}?until=${options.expiresAt.toISOString()}`,
expiresAt: options.expiresAt,
}),
});The mock still validates paths, applies failure injection, and checks object existence before invoking your signer.
Trigger registration
Direct trigger registration lets real Firebase Functions v1/v2 storage handlers run when mock object events occur.
Matching events are enqueued per registered trigger. A storage operation does not wait for trigger completion, and the next matching event for that registration will not start until the previous delivery has finished. This keeps back-to-back writes deterministic without making storage writes synchronously complete trigger side effects.
v2 trigger example
import { onObjectFinalized } from 'firebase-functions/v2/storage';
import { StorageController } from '@firebase-bridge/storage-mock';
import { registerTrigger } from '@firebase-bridge/storage-mock/v2';
const ctrl = new StorageController({ defaultBucket: 'imports.test' });
const bucket = ctrl.service().bucket();
const calls: string[] = [];
const dispose = registerTrigger(
ctrl,
onObjectFinalized({ bucket: 'imports.test' }, (event) => {
calls.push(`${event.bucket}:${event.data.name}`);
}),
(record) => record.path.endsWith('.csv')
);
await bucket.writeText('skip.txt', 'skip');
await bucket.writeText('keep.csv', 'id,name\n1,Ada\n');
await new Promise((resolve) => setTimeout(resolve, 10));
expect(calls).toEqual(['imports.test:keep.csv']);
dispose();v1 trigger example
import * as functions from 'firebase-functions/v1';
import { registerTrigger } from '@firebase-bridge/storage-mock/v1';
const dispose = registerTrigger(
ctrl,
functions.storage.bucket('imports.test').object().onFinalize((object, context) => {
expect(object.bucket).toBe('imports.test');
expect(context.eventType).toBe('google.storage.object.finalize');
})
);registerTrigger() accepts either a predicate function or an options object:
registerTrigger(ctrl, handler, {
predicate: (record) => record.path.endsWith('.csv'),
onBefore: (record) => undefined,
onAfter: (record) => undefined,
onError: ({ origin, arg, cause }) => undefined,
});Errors from predicates, hooks, or handlers are reported to onError() and swallowed so test harness delivery can continue.
Trigger orchestrator
StorageTriggerOrchestrator is a higher-level harness for larger trigger suites. It registers keyed trigger stubs, lets tests enable/disable them, waits for activity, observes phases, and captures errors.
import { onObjectDeleted, onObjectFinalized } from 'firebase-functions/v2/storage';
import { StorageController, StorageTriggerOrchestrator } from '@firebase-bridge/storage-mock';
enum TriggerKey {
Finalized = 'Finalized',
Deleted = 'Deleted',
}
const ctrl = new StorageController({ defaultBucket: 'imports.test' });
const bucket = ctrl.service().bucket();
const orchestrator = new StorageTriggerOrchestrator<TriggerKey>(ctrl, (reg) => {
reg.v2(
TriggerKey.Finalized,
onObjectFinalized({ bucket: 'imports.test' }, async (event) => {
console.log(event.data.name);
})
);
reg.v2(
TriggerKey.Deleted,
onObjectDeleted({ bucket: 'imports.test' }, async () => {
throw new Error('delete failed');
})
);
});
const done = orchestrator.waitOne(TriggerKey.Finalized);
await bucket.writeText('manifest.json', '{}');
await expect(done).resolves.toMatchObject({ completedCount: 1 });
const failed = orchestrator.waitOneError(TriggerKey.Deleted);
await bucket.delete('manifest.json');
await expect(failed).resolves.toMatchObject({ errorCount: 1 });
orchestrator.disable(TriggerKey.Finalized);
orchestrator.enable(TriggerKey.Finalized);
orchestrator.dispose();Useful methods:
all(enable),enable(...keys),disable(...keys),isEnabled(key)observe(key, observer),observeAll(observer),on(key, callback),onAll(callback)watchErrors(callback)wait(key, predicate, options),waitOne(key, options)waitError(key, predicate, options),waitOneError(key, options)attach(),detach(),reset(),dispose()getStats(key)
Event behavior
The mock emits object events only after successful operations:
finalizedafterwrite()/writeText()/ object data replacement.metadata-updatedaftersetMetadata().deletedafter a real delete.archivedis recognized by the trigger harness, but normal mock operations do not currently emit archive events.
No event is emitted for failed operations or delete({ ignoreMissing: true }) when the object is absent.
Trigger delivery is asynchronous and sequential per registration. For direct registerTrigger() tests, wait for your handler's observable side effect or yield the event loop before asserting. For larger suites, prefer StorageTriggerOrchestrator.waitOne() / wait() over fixed sleeps.
Validation and errors
The mock follows the same intentionally narrow validation policy as @firebase-bridge/cloud-storage:
- Explicit bucket ids must be non-empty and must not contain
/or control characters. - Object paths must be non-empty and must not start with
/or contain control characters.
Portable failures reject with CloudStorageError. Match on error.code, not provider-specific internals.
Package exports
Main package:
StorageControllerStorageTriggerOrchestrator- test-control, trigger, listener, wait, and observer types
Subpath exports:
@firebase-bridge/storage-mock/v1:registerTrigger,TriggerPayload@firebase-bridge/storage-mock/v2:registerTrigger,StorageEventLike
Non-goals
This package does not provide:
- Direct monkey-patching of
firebase-admin.storage() - Storage Rules emulation
- resumable uploads or streaming APIs
- ACL/public access behavior
- retry/backoff simulation
- Google Cloud Storage emulator compatibility
- real cryptographic signed URLs
Troubleshooting
My production code still calls real Storage Ensure production code receives a
CloudStorageServicefrom@firebase-bridge/cloud-storageor your own facade. Code that directly callsfirebase-admin.storage()cannot be automatically redirected to this mock.My trigger did not run Make sure you registered the trigger against the same
StorageController, used the correct v1/v2 subpath, and allowed the asynchronous handler to flush before asserting.My waiter timed out Check that the trigger is enabled, the bucket filter matches, and the predicate returns
true.A reset caused old events to disappear This is intentional. Controller epochs advance on reset/delete so orchestrators ignore stale events from a previous lifecycle.
Versioning and compatibility
- Depends on
@firebase-bridge/cloud-storage. - Peer dependency:
firebase-functionsfor trigger wrappers. - Node.js >= 18.
- ESM and CJS consumers are supported through package exports.
Contributing
This project is in minimal-maintainer mode. Issues with small reproducible examples are welcome. PRs should be focused on bug fixes, docs improvements, or fidelity gaps with tests.
License
Apache-2.0 (c) 2026 Bryce Marshall
Trademarks and attribution
This project is not affiliated with, associated with, or endorsed by Google LLC. "Firebase", "Cloud Storage", and "Google Cloud Storage" are trademarks of Google LLC. Names are used solely to identify compatibility and do not imply endorsement.
