diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a5dcd4..97e7056 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: {} # Least-privilege GITHUB_TOKEN: every job here only reads the repo (checkout, # install, lint, typecheck, test, audit). Widen per-job if one ever needs more. @@ -48,6 +49,33 @@ jobs: - name: Test run: npm test + # Exercises a real Pinecone upsert/query round-trip (see tests/integration). + # Restricted to push/workflow_dispatch — never pull_request — because it's + # credentialed and a PR (including from a fork) must not get access to the + # PINECONE_API_KEY secret. The test itself also self-skips when the secret + # is absent, so a run against a fork or before secrets are configured is a + # clean no-op rather than a failure. + integration-test: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 22.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Integration test (self-skips without PINECONE_API_KEY) + run: npm run test:integration + env: + PINECONE_API_KEY: ${{ secrets.PINECONE_API_KEY }} + PINECONE_CLOUD: ${{ secrets.PINECONE_CLOUD }} + PINECONE_REGION: ${{ secrets.PINECONE_REGION }} + # Fails the build on high/critical advisories in production dependencies. # Scoped to prod deps so a dev-only advisory can't wedge unrelated PRs, and to # high/critical so low/moderate noise doesn't block work. diff --git a/package.json b/package.json index 6fd216f..a5fbf70 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:integration": "vitest run -c vitest.integration.config.ts", "lint": "eslint src", "lint:fix": "npm run lint -- --fix", "format": "prettier --write \"**/*.ts\"", diff --git a/tests/integration/pinecone.test.ts b/tests/integration/pinecone.test.ts new file mode 100644 index 0000000..8a17630 --- /dev/null +++ b/tests/integration/pinecone.test.ts @@ -0,0 +1,100 @@ +// Exercises a real upsert/query round-trip against a live Pinecone project — +// the gap that let a v2→v8 response-shape mismatch (see #5) ship undetected, +// since unit tests mock the SDK entirely. +// +// Skips outright when PINECONE_API_KEY is unset so `npm test` and PR runs +// stay green and credential-free; only `npm run test:integration` (wired into +// CI on main/workflow_dispatch, see ci.yml) actually exercises it. +import { randomUUID } from "crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + Pinecone, + type Index, + type PineconeRecord, +} from "@pinecone-database/pinecone"; +import { getEnv } from "../../src/utils/env.ts"; +import { chunkedUpsert } from "../../src/utils/chunkedUpsert.ts"; + +const DIMENSION = 384; +const NAMESPACE = "integration-test"; + +const randomVector = (length: number): number[] => + Array.from({ length }, () => Math.random()); + +// Serverless upserts are eventually consistent, so a query issued right after +// an upsert can legitimately return fewer than the expected matches. Poll +// instead of a fixed sleep. +async function waitForMatches( + index: Index, + vector: number[], + expected: number, + { attempts = 10, delayMs = 3000 } = {} +) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + const result = await index.namespace(NAMESPACE).query({ + vector, + topK: expected, + includeMetadata: true, + includeValues: true, + }); + if ((result.matches?.length ?? 0) >= expected) { + return result; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + throw new Error(`Query never returned ${expected} match(es) in time`); +} + +describe.skipIf(!process.env.PINECONE_API_KEY)("Pinecone integration", () => { + const indexName = `recommender-it-${randomUUID().slice(0, 8)}`; + let pinecone: Pinecone; + let index: Index; + let indexCreated = false; + + beforeAll(async () => { + pinecone = new Pinecone({ apiKey: getEnv("PINECONE_API_KEY") }); + await pinecone.createIndex({ + name: indexName, + dimension: DIMENSION, + metric: "cosine", + spec: { + serverless: { + cloud: getEnv("PINECONE_CLOUD"), + region: getEnv("PINECONE_REGION"), + }, + }, + waitUntilReady: true, + }); + indexCreated = true; + index = pinecone.index(indexName); + }, 120_000); + + afterAll(async () => { + if (indexCreated) { + await pinecone.deleteIndex(indexName); + } + }); + + it("upserts and queries records through the real chunkedUpsert path", async () => { + const records: PineconeRecord[] = Array.from({ length: 5 }, (_, i) => ({ + id: `article-${i}`, + values: randomVector(DIMENSION), + metadata: { title: `Article ${i}` }, + })); + + await chunkedUpsert(index, records, NAMESPACE, 2); + + const result = await waitForMatches( + index, + records[0].values as number[], + records.length + ); + + expect(result.matches?.length).toBeGreaterThan(0); + for (const match of result.matches ?? []) { + expect(match.id).toMatch(/^article-\d+$/); + expect(match.values).toHaveLength(DIMENSION); + expect(match.metadata?.title).toBeDefined(); + } + }, 60_000); +}); diff --git a/vitest.config.ts b/vitest.config.ts index ba29bd1..cd88b8b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,12 @@ -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; +// Integration tests live under tests/integration and run via the separate +// vitest.integration.config.ts / `npm run test:integration`, never here — they +// hit a real Pinecone project and must not run on untrusted PRs. export default defineConfig({ test: { include: ["tests/**/*.test.ts"], + exclude: [...configDefaults.exclude, "tests/integration/**"], environment: "node", }, }); diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 0000000..55e7f0d --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +// Separate from vitest.config.ts so a plain `npm test` never touches the +// network: this config's tests hit a real Pinecone project and are run +// explicitly via `npm run test:integration` (see ci.yml's integration-test +// job). +export default defineConfig({ + test: { + include: ["tests/integration/**/*.test.ts"], + environment: "node", + testTimeout: 120_000, + hookTimeout: 120_000, + }, +});