feat(ci): Cosmos emulator smoke job exercises partition-key paths

The existing 380-test backend suite runs entirely against the in-memory
datastore provider, which treats every partition-key value as equivalent.
This hid one entire class of bug — partition-key mismatches — until
production. D7 closes that gap.

Implementation:

- backend/src/test-helpers.ts adds useCosmosDatastore() that swaps the
  active provider for CosmosDatastoreProvider using COSMOS_ENDPOINT /
  COSMOS_KEY / COSMOS_DATABASE. Throws synchronously when env is missing
  so a misconfigured run fails loudly instead of silently falling back
  to in-memory.

- backend/vitest.config.ts now excludes src/**/*.cosmos.test.ts so the
  default 'pnpm test' run stays green for contributors without Docker.

- backend/vitest.cosmos.config.ts (new) includes ONLY *.cosmos.test.ts,
  bumps testTimeout to 30s / hookTimeout to 60s for the real client
  round-trips, and locks DB_PROVIDER=cosmos in test env.

- backend/src/cosmos.smoke.cosmos.test.ts (new) covers the four most
  important partition-key contracts in NoteLett:
    workspaces      /userId
    notes           /workspaceId
    note_tasks      /workspaceId
    note_shares     /workspaceId  (full create → resolve → delete → null)
  Each test also asserts that a wrong-partition-key lookup returns null,
  which is the failure mode the in-memory provider cannot simulate.

- backend/package.json adds 'test:cosmos' script.

- .github/workflows/ci.yml gains a backend-cosmos job that boots the
  official mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator
  container as a service, waits for it to be ready (60 × 5s polls of
  /_explorer/emulator.pem), then runs pnpm test:cosmos against it.
  The job depends on the existing backend job so the emulator only
  spins up after unit tests pass.

Verified locally:
- pnpm --filter @notelett/backend test: 380/380 (cosmos suite excluded)
- vitest list --config vitest.cosmos.config.ts: 4 tests under the cosmos
  smoke suite, as designed
- pnpm run verify: end-to-end green (backend 380/380, web 96/96,
  mobile 97/97)
- ci.yml passes Python yaml.safe_load

CI verification: the new job will execute on the next push. Local
verification against the emulator requires Docker on the dev host.
This commit is contained in:
saravanakumardb1 2026-05-23 00:25:24 -07:00
parent 3c4d46f3ad
commit 79e936bd68
6 changed files with 295 additions and 1 deletions

View File

@ -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

View File

@ -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/"
},

View File

@ -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<string, ContainerConfig> = {
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();
});
});

View File

@ -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<typeof Fastify>) => Promise<void>,
) {

View File

@ -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',

View File

@ -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',
},
},
});