diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b27ab6..e3e2fd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,85 @@ jobs: - name: Backend build run: pnpm --filter @notelett/backend run build + backend-cosmos: + # Cosmos-emulator smoke — exercises partition-key paths that the + # in-memory provider cannot detect. Runs only the *.cosmos.test.ts + # suite via vitest.cosmos.config.ts. See backend/src/cosmos.smoke.cosmos.test.ts + # for the contract. + name: Backend — Cosmos emulator smoke (partition keys) + runs-on: ubuntu-latest + needs: backend + services: + cosmos: + image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest + ports: + - 8081:8081 + - 10251:10251 + - 10252:10252 + - 10253:10253 + - 10254:10254 + env: + AZURE_COSMOS_EMULATOR_PARTITION_COUNT: 4 + AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE: false + options: >- + --memory 3g + steps: + - uses: actions/checkout@v4 + + - name: Checkout common-plat (for @bytelyst/* packages) + uses: actions/checkout@v4 + with: + repository: saravanakumardb1/learning_ai_common_plat + path: learning_ai_common_plat + token: ${{ secrets.GH_PAT }} + + - name: Link common-platform workspace path + run: | + ln -sfn "$GITHUB_WORKSPACE/learning_ai_common_plat" ../learning_ai_common_plat + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Enable pnpm + run: corepack enable + + - name: Build @bytelyst/* packages + working-directory: learning_ai_common_plat + run: | + pnpm install --frozen-lockfile + pnpm build + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Wait for Cosmos emulator to be ready + run: | + for i in {1..60}; do + if curl -ks --max-time 5 https://localhost:8081/_explorer/emulator.pem >/dev/null 2>&1; then + echo "Cosmos emulator is ready (after ${i}s)" + exit 0 + fi + echo "Waiting for Cosmos emulator... ($i/60)" + sleep 5 + done + echo "Cosmos emulator failed to become ready in 5 minutes" >&2 + exit 1 + + - name: Run Cosmos smoke suite + run: pnpm --filter @notelett/backend run test:cosmos + env: + DB_PROVIDER: cosmos + COSMOS_ENDPOINT: https://localhost:8081 + # Well-known emulator dev key. NOT a secret. + # https://learn.microsoft.com/en-us/azure/cosmos-db/local-emulator + COSMOS_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw== + COSMOS_DATABASE: notelett_test + NODE_TLS_REJECT_UNAUTHORIZED: 0 + JWT_SECRET: ci-test-secret-at-least-32-characters-long + web: name: Web — typecheck + test + build runs-on: ubuntu-latest diff --git a/backend/package.json b/backend/package.json index 7383c70..e3048ec 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "start": "node dist/server.js", "test": "vitest run", "test:watch": "vitest", + "test:cosmos": "vitest run --config vitest.cosmos.config.ts", "typecheck": "tsc --noEmit", "lint": "eslint src/" }, diff --git a/backend/src/cosmos.smoke.cosmos.test.ts b/backend/src/cosmos.smoke.cosmos.test.ts new file mode 100644 index 0000000..394115e --- /dev/null +++ b/backend/src/cosmos.smoke.cosmos.test.ts @@ -0,0 +1,156 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { initializeAllContainers, registerContainers } from '@bytelyst/cosmos'; +import type { ContainerConfig } from '@bytelyst/cosmos'; +import { useCosmosDatastore } from './test-helpers.js'; +import { createNote, getNote } from './modules/notes/repository.js'; +import { createWorkspace, getWorkspace } from './modules/workspaces/repository.js'; +import { createNoteTask, getNoteTask } from './modules/note-tasks/repository.js'; +import { + createNoteShare, + deleteShare, + findShareByToken, +} from './modules/note-shares/repository.js'; +import type { NoteDoc } from './modules/notes/types.js'; +import type { WorkspaceDoc } from './modules/workspaces/types.js'; +import type { NoteTaskDoc } from './modules/note-tasks/types.js'; +import type { NoteShareDoc } from './modules/note-shares/types.js'; + +/** + * Cosmos-emulator smoke suite. + * + * Exercises the partition-key paths that in-memory tests cannot detect: + * - notes container partitioned by /workspaceId (NOT /userId) + * - workspaces container partitioned by /userId + * - note_tasks container partitioned by /workspaceId + * - note_shares container partitioned by /workspaceId + * + * The point is to detect partition-key mismatches BEFORE they reach + * production. A misaligned partition key surfaces only against real + * Cosmos (the in-memory provider treats every key equivalently). + */ + +const SUITE_ID = `cosmos-smoke-${Date.now()}`; +const USER_ID = `user-${SUITE_ID}`; +const WORKSPACE_ID = `ws-${SUITE_ID}`; +const PRODUCT_ID = 'notelett'; + +// Subset of containers used in this smoke. Keep the registration explicit +// so the suite is independent of any startup ordering elsewhere. +const SMOKE_CONTAINERS: Record = { + notes: { partitionKeyPath: '/workspaceId' }, + workspaces: { partitionKeyPath: '/userId' }, + note_tasks: { partitionKeyPath: '/workspaceId' }, + note_shares: { partitionKeyPath: '/workspaceId' }, +}; + +describe('Cosmos emulator smoke (partition-key paths)', () => { + beforeAll(async () => { + useCosmosDatastore(); + registerContainers(SMOKE_CONTAINERS); + await initializeAllContainers(); + }, 60_000); + + afterAll(async () => { + // Nothing to do — the test database is throwaway and reset by CI. + }); + + it('workspaces: writes and reads using /userId as the partition key', async () => { + const doc: WorkspaceDoc = { + id: WORKSPACE_ID, + productId: PRODUCT_ID, + userId: USER_ID, + name: 'Cosmos smoke workspace', + members: [{ userId: USER_ID, role: 'owner' }], + createdAt: new Date().toISOString(), + createdBy: USER_ID, + updatedAt: new Date().toISOString(), + updatedBy: USER_ID, + }; + await createWorkspace(doc); + + const fetched = await getWorkspace(WORKSPACE_ID, USER_ID); + expect(fetched).not.toBeNull(); + expect(fetched?.id).toBe(WORKSPACE_ID); + expect(fetched?.userId).toBe(USER_ID); + + // Wrong partition key → must return null (proves /userId is the PK). + const wrongPk = await getWorkspace(WORKSPACE_ID, 'unknown-user'); + expect(wrongPk).toBeNull(); + }); + + it('notes: writes and reads using /workspaceId as the partition key', async () => { + const noteId = `note-${SUITE_ID}`; + const doc: NoteDoc = { + id: noteId, + productId: PRODUCT_ID, + workspaceId: WORKSPACE_ID, + userId: USER_ID, + title: 'Cosmos smoke note', + body: 'partition-key test', + tags: [], + links: [], + status: 'active', + sourceType: 'manual', + createdAt: new Date().toISOString(), + createdBy: USER_ID, + updatedAt: new Date().toISOString(), + updatedBy: USER_ID, + }; + await createNote(doc); + + const fetched = await getNote(noteId, WORKSPACE_ID); + expect(fetched).not.toBeNull(); + expect(fetched?.id).toBe(noteId); + + // Wrong partition key → must return null. + const wrongPk = await getNote(noteId, 'unknown-workspace'); + expect(wrongPk).toBeNull(); + }); + + it('note_tasks: writes and reads using /workspaceId as the partition key', async () => { + const taskId = `task-${SUITE_ID}`; + const noteId = `note-${SUITE_ID}`; + const doc: NoteTaskDoc = { + id: taskId, + productId: PRODUCT_ID, + workspaceId: WORKSPACE_ID, + userId: USER_ID, + noteId, + title: 'Cosmos smoke task', + status: 'open', + source: 'manual', + createdAt: new Date().toISOString(), + createdBy: USER_ID, + updatedAt: new Date().toISOString(), + updatedBy: USER_ID, + }; + await createNoteTask(doc); + + const fetched = await getNoteTask(taskId, WORKSPACE_ID); + expect(fetched).not.toBeNull(); + expect(fetched?.id).toBe(taskId); + + const wrongPk = await getNoteTask(taskId, 'unknown-workspace'); + expect(wrongPk).toBeNull(); + }); + + it('note_shares: revocation through hard-delete works against live Cosmos', async () => { + const shareToken = `tok-${SUITE_ID}`; + const doc: NoteShareDoc = { + id: `sh-${shareToken}`, + productId: PRODUCT_ID, + workspaceId: WORKSPACE_ID, + userId: USER_ID, + noteId: `note-${SUITE_ID}`, + shareToken, + createdAt: new Date().toISOString(), + }; + await createNoteShare(doc); + + expect(await findShareByToken(shareToken, PRODUCT_ID)).not.toBeNull(); + + await deleteShare(doc.id, doc.workspaceId); + + expect(await findShareByToken(shareToken, PRODUCT_ID)).toBeNull(); + }); +}); diff --git a/backend/src/test-helpers.ts b/backend/src/test-helpers.ts index 20fa48f..8545913 100644 --- a/backend/src/test-helpers.ts +++ b/backend/src/test-helpers.ts @@ -1,5 +1,5 @@ import Fastify from 'fastify'; -import { MemoryDatastoreProvider } from '@bytelyst/datastore'; +import { CosmosDatastoreProvider, MemoryDatastoreProvider } from '@bytelyst/datastore'; import { setProvider } from './lib/datastore.js'; export function resetMemoryDatastore(): void { @@ -7,6 +7,26 @@ export function resetMemoryDatastore(): void { setProvider(provider); } +/** + * Bind the active datastore to a live Azure Cosmos endpoint. Only used by + * `*.cosmos.test.ts` suites running under `vitest.cosmos.config.ts`, which + * is gated on COSMOS_ENDPOINT being set (CI: cosmos-emulator service). + * + * Throws synchronously if the required env vars are missing so individual + * tests fail loudly instead of silently falling back to in-memory. + */ +export function useCosmosDatastore(): void { + if (!process.env.COSMOS_ENDPOINT || !process.env.COSMOS_KEY) { + throw new Error('useCosmosDatastore() requires COSMOS_ENDPOINT and COSMOS_KEY to be set'); + } + const provider = new CosmosDatastoreProvider({ + endpoint: process.env.COSMOS_ENDPOINT, + key: process.env.COSMOS_KEY, + database: process.env.COSMOS_DATABASE || 'notelett_test', + }); + setProvider(provider); +} + export async function buildTestApp( routePlugin: (app: ReturnType) => Promise, ) { diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 4e6a323..8dcf0ea 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -4,6 +4,11 @@ export default defineConfig({ test: { globals: true, include: ['src/**/*.test.ts'], + // Cosmos-emulator suites live alongside unit/integration tests but + // require a live emulator; they are run by vitest.cosmos.config.ts and + // must be excluded from the default `pnpm test` run so contributors + // without Docker still get green locally. + exclude: ['**/node_modules/**', 'src/**/*.cosmos.test.ts'], passWithNoTests: true, env: { ALLOW_ANONYMOUS_DEV: 'true', diff --git a/backend/vitest.cosmos.config.ts b/backend/vitest.cosmos.config.ts new file mode 100644 index 0000000..f622d4f --- /dev/null +++ b/backend/vitest.cosmos.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Cosmos-emulator integration test runner. + * + * Only includes files matching `**\/*.cosmos.test.ts`. These suites must NOT + * run under the default vitest config because they need a live Azure Cosmos + * endpoint (the official `mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator` + * image in CI, or a local emulator instance). + * + * Required env at runtime: + * COSMOS_ENDPOINT e.g. https://localhost:8081 + * COSMOS_KEY the well-known dev key for the emulator + * COSMOS_DATABASE defaults to "notelett_test" + * NODE_TLS_REJECT_UNAUTHORIZED=0 (emulator uses a self-signed cert) + * + * Run via: `pnpm --filter @notelett/backend run test:cosmos` + */ +export default defineConfig({ + test: { + globals: true, + include: ['src/**/*.cosmos.test.ts'], + exclude: ['**/node_modules/**'], + // Cosmos calls are slower than in-memory; give the suite headroom. + testTimeout: 30_000, + hookTimeout: 60_000, + passWithNoTests: false, + env: { + ALLOW_ANONYMOUS_DEV: 'true', + DB_PROVIDER: 'cosmos', + }, + }, +});