diff --git a/packages/leaderboard/README.md b/packages/leaderboard/README.md index a5c008b..daeb0fd 100644 --- a/packages/leaderboard/README.md +++ b/packages/leaderboard/README.md @@ -149,11 +149,30 @@ Want to author your own custom elements this way? Check out ## Choosing a backend +### Bring your own backend instance (injection) + +Both adapters let the **consumer own the backend instance** — including a +privileged/admin-level or server-side one — rather than the package creating its +own. This is the injection point per adapter: + +| Adapter | Injection point | Internal init fallback | +| ---------- | -------------------------- | ----------------------------------- | +| Supabase | `client` (a supabase-js client you build) — **always** consumer-supplied; the package takes no supabase dependency | none — a client is required | +| Firebase | `store` (a Firestore instance you build) | `firebaseConfig` → the package initializes its own app | + +Supply a privileged instance and every read/write runs against it — the package +adds no auth or app lifecycle of its own. + ### Firebase (Firestore) ```js import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js' + +// (a) let the package initialize from a public config: const adapter = new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }) + +// (b) OR inject a Firestore instance you built (e.g. privileged/server-side): +const adapter = new FirebaseAdapter({ store: myFirestore, namespace: 'mw' }) ``` Needs the `firebase` peer dependency. Uses collections @@ -162,9 +181,10 @@ Needs the `firebase` peer dependency. Uses collections and all-time sorts by `score`, so only Firestore's automatic single-field indexes are needed — no composite indexes to deploy. -For local development, pass `emulator` to run against the +With an injected `store` the package initializes nothing and owns no app +lifecycle. For internal init, pass `emulator` to run against the [Firestore emulator](https://firebase.google.com/docs/emulator-suite) — no cloud, -no deploy: +no deploy (wire the emulator into your own store if you inject one): ```js new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } }) diff --git a/packages/leaderboard/adapters/firebase.js b/packages/leaderboard/adapters/firebase.js index a80c012..faf5135 100644 --- a/packages/leaderboard/adapters/firebase.js +++ b/packages/leaderboard/adapters/firebase.js @@ -12,13 +12,28 @@ import { export class FirebaseAdapter { /** + * Supply EITHER a ready Firestore instance via `store` (the injection point — + * e.g. a privileged/server-side setup, or an app you already initialized), OR a + * `firebaseConfig` for the package to initialize its own app. `store` wins when + * both are given; with an injected store the package initializes nothing and + * owns no app lifecycle (so `emulator` — a convenience of internal init — is + * ignored; wire the emulator into your own store). + * * @param {Object} options - * @param {Object} options.firebaseConfig - Firebase app config (public; access governed by security rules) + * @param {Object} [options.store] - a Firestore instance to use as-is (injection point) + * @param {Object} [options.firebaseConfig] - Firebase app config for internal init (public; access governed by security rules) * @param {String} [options.namespace] - collection prefix - * @param {{ host?: string, port?: number }} [options.emulator] - point at a local Firestore emulator (dev/test only) + * @param {{ host?: string, port?: number }} [options.emulator] - point the internally-created store at a local Firestore emulator (dev/test only) */ constructor(options = {}) { this.namespace = options.namespace || 'lb' + if (options.store) { + this.store = options.store + return + } + if (!options.firebaseConfig) { + throw new TypeError('FirebaseAdapter: provide either `store` (a Firestore instance) or `firebaseConfig`') + } const app = initializeApp(options.firebaseConfig) this.store = getFirestore(app) if (options.emulator) { diff --git a/packages/leaderboard/test/firebase-adapter.test.js b/packages/leaderboard/test/firebase-adapter.test.js new file mode 100644 index 0000000..b7afd8d --- /dev/null +++ b/packages/leaderboard/test/firebase-adapter.test.js @@ -0,0 +1,102 @@ +// @ts-check +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// Stub the firebase SDK at the adapter boundary so no real app/network is touched. +const fb = vi.hoisted(() => ({ + initializeApp: vi.fn(() => ({ __app: true })), + getFirestore: vi.fn(() => ({ __internalStore: true })), + connectFirestoreEmulator: vi.fn(), + doc: vi.fn((...args) => ({ __ref: args })), + getDoc: vi.fn(async () => ({ data: () => ({ passingStatus: 'ok' }) })), + getDocs: vi.fn(async () => ({ docs: [] })), + setDoc: vi.fn(async () => {}), + collection: vi.fn((...args) => ({ __col: args })), + query: vi.fn((...args) => ({ __q: args })), + where: vi.fn((...args) => ({ __where: args })), + orderBy: vi.fn((...args) => ({ __order: args })), + limit: vi.fn((...args) => ({ __limit: args })) +})) + +vi.mock('firebase/app', () => ({ initializeApp: fb.initializeApp })) +vi.mock('firebase/firestore/lite', () => ({ + getFirestore: fb.getFirestore, + connectFirestoreEmulator: fb.connectFirestoreEmulator, + doc: fb.doc, + getDoc: fb.getDoc, + getDocs: fb.getDocs, + setDoc: fb.setDoc, + collection: fb.collection, + query: fb.query, + where: fb.where, + orderBy: fb.orderBy, + limit: fb.limit +})) + +import { FirebaseAdapter } from '../adapters/firebase.js' + +beforeEach(() => vi.clearAllMocks()) + +describe('FirebaseAdapter — consumer-supplied (injected) store', () => { + it('uses an injected store as-is and initializes nothing', () => { + // Stand-in for a privileged / server-side Firestore instance. + const store = { __adminStore: true } + const adapter = new FirebaseAdapter({ store, namespace: 'mw' }) + expect(adapter.store).toBe(store) + expect(fb.initializeApp).not.toHaveBeenCalled() + expect(fb.getFirestore).not.toHaveBeenCalled() + }) + + it('performs a write through the injected store', async () => { + const store = { __adminStore: true } + const adapter = new FirebaseAdapter({ store, namespace: 'mw' }) + const entry = { name: 'A', score: 42, category: 'beginner', time_stamp: 123 } + + await adapter.addScore('beginner', entry) + + // The collection was built from OUR store, and the entry was written. + expect(fb.collection).toHaveBeenCalledWith(store, 'mw-scores', 'beginner', 'games') + expect(fb.setDoc).toHaveBeenCalledTimes(1) + expect(fb.setDoc.mock.calls[0][1]).toEqual(entry) + }) + + it('archives through the injected store', async () => { + const store = { __adminStore: true } + const adapter = new FirebaseAdapter({ store, namespace: 'mw' }) + await adapter.archive({ playerId: 'p1', score: 9, category: 'beginner', time_stamp: 5 }) + expect(fb.doc).toHaveBeenCalledWith(store, 'mw-all', 'p1', 'games', expect.any(String)) + expect(fb.setDoc).toHaveBeenCalledTimes(1) + }) + + it('does not auto-connect the emulator for an injected store (consumer owns it)', () => { + new FirebaseAdapter({ store: { __s: 1 }, namespace: 'mw', emulator: { host: 'x', port: 1 } }) + expect(fb.connectFirestoreEmulator).not.toHaveBeenCalled() + }) + + it('prefers the injected store when both store and firebaseConfig are given', () => { + const store = { __adminStore: true } + const adapter = new FirebaseAdapter({ store, firebaseConfig: { projectId: 'p' } }) + expect(adapter.store).toBe(store) + expect(fb.initializeApp).not.toHaveBeenCalled() + }) +}) + +describe('FirebaseAdapter — internal init (existing path, unbroken)', () => { + it('still initializes its own store from firebaseConfig and writes', async () => { + const adapter = new FirebaseAdapter({ firebaseConfig: { projectId: 'p' }, namespace: 'mw' }) + expect(fb.initializeApp).toHaveBeenCalledWith({ projectId: 'p' }) + expect(fb.getFirestore).toHaveBeenCalledTimes(1) + expect(adapter.store).toEqual({ __internalStore: true }) + + await adapter.addScore('beginner', { score: 1 }) + expect(fb.setDoc).toHaveBeenCalledTimes(1) + }) + + it('still connects the emulator when configured', () => { + new FirebaseAdapter({ firebaseConfig: {}, emulator: { host: '127.0.0.1', port: 8080 } }) + expect(fb.connectFirestoreEmulator).toHaveBeenCalledWith({ __internalStore: true }, '127.0.0.1', 8080) + }) + + it('throws a clear error when neither store nor firebaseConfig is given', () => { + expect(() => new FirebaseAdapter({})).toThrow(TypeError) + }) +}) diff --git a/packages/leaderboard/test/supabase-adapter.test.js b/packages/leaderboard/test/supabase-adapter.test.js new file mode 100644 index 0000000..392babe --- /dev/null +++ b/packages/leaderboard/test/supabase-adapter.test.js @@ -0,0 +1,48 @@ +// @ts-check +import { describe, it, expect, vi } from 'vitest' +import { SupabaseAdapter } from '../adapters/supabase.js' + +// A chainable stand-in for a supabase-js client (consumer-constructed — the +// package takes no supabase dependency). A privileged/service-role client is +// supplied the same way. +function mockClient() { + const insert = vi.fn(async () => ({ error: null })) + const from = vi.fn(() => ({ insert })) + return { from, insert } +} + +describe('SupabaseAdapter — consumer-supplied client (injection point exists)', () => { + it('uses the injected client as-is', () => { + const client = mockClient() + const adapter = new SupabaseAdapter({ client, namespace: 'mw' }) + expect(adapter.client).toBe(client) + }) + + it('performs a write through the injected client', async () => { + const client = mockClient() + const adapter = new SupabaseAdapter({ client, namespace: 'mw' }) + + await adapter.addScore('beginner', { + name: 'A', playerId: 'p1', score: 5, category: 'beginner', time_stamp: 't' + }) + + expect(client.from).toHaveBeenCalledWith('mw_scores') + expect(client.insert).toHaveBeenCalledTimes(1) + expect(client.insert.mock.calls[0][0]).toMatchObject({ name: 'A', player_id: 'p1', score: 5 }) + }) + + it('archives through the injected client', async () => { + const client = mockClient() + const adapter = new SupabaseAdapter({ client, namespace: 'mw' }) + await adapter.archive({ playerId: 'p1', score: 9, category: 'beginner', time_stamp: 't' }) + expect(client.from).toHaveBeenCalledWith('mw_archive') + expect(client.insert.mock.calls[0][0]).toMatchObject({ player_id: 'p1', score: 9 }) + }) + + it('surfaces backend errors from the injected client', async () => { + const insert = vi.fn(async () => ({ error: new Error('permission denied') })) + const client = { from: vi.fn(() => ({ insert })) } + const adapter = new SupabaseAdapter({ client, namespace: 'mw' }) + await expect(adapter.addScore('beginner', { playerId: 'p' })).rejects.toThrow('permission denied') + }) +})