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:
parent
3c4d46f3ad
commit
79e936bd68
79
.github/workflows/ci.yml
vendored
79
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
@ -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/"
|
||||
},
|
||||
|
||||
156
backend/src/cosmos.smoke.cosmos.test.ts
Normal file
156
backend/src/cosmos.smoke.cosmos.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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>,
|
||||
) {
|
||||
|
||||
@ -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',
|
||||
|
||||
33
backend/vitest.cosmos.config.ts
Normal file
33
backend/vitest.cosmos.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user