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
|
- name: Backend build
|
||||||
run: pnpm --filter @notelett/backend run 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:
|
web:
|
||||||
name: Web — typecheck + test + build
|
name: Web — typecheck + test + build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"test:cosmos": "vitest run --config vitest.cosmos.config.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint src/"
|
"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 Fastify from 'fastify';
|
||||||
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
import { CosmosDatastoreProvider, MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||||
import { setProvider } from './lib/datastore.js';
|
import { setProvider } from './lib/datastore.js';
|
||||||
|
|
||||||
export function resetMemoryDatastore(): void {
|
export function resetMemoryDatastore(): void {
|
||||||
@ -7,6 +7,26 @@ export function resetMemoryDatastore(): void {
|
|||||||
setProvider(provider);
|
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(
|
export async function buildTestApp(
|
||||||
routePlugin: (app: ReturnType<typeof Fastify>) => Promise<void>,
|
routePlugin: (app: ReturnType<typeof Fastify>) => Promise<void>,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -4,6 +4,11 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ['src/**/*.test.ts'],
|
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,
|
passWithNoTests: true,
|
||||||
env: {
|
env: {
|
||||||
ALLOW_ANONYMOUS_DEV: 'true',
|
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