Testing with the contract suite
Every plugin must pass the same behavioural suite. It is what makes two very different systems interchangeable to the app:
if both pass, the app can treat them identically. The suite lives in the SDK’s /testing entry point and runs on
Vitest.
Run it
Section titled “Run it”Describe the dataset your test server serves (the fixture), give it good and bad credentials, and say how to create a runtime. The suite does the rest.
// 1. The shared contract: every plugin must pass the same behavioural test suite.integrationContractTests({ name: shelfieManifest.name, fixture: { rootContainerRemoteId: 'p1', nestedContainerRemoteId: 'p3', expectedPath: ['Garage', 'Shelf A', 'Box 04'], itemRemoteId: 't1', itemContainerRemoteId: 'p3', searchText: 'drill', missingRemoteId: 'does-not-exist' }, validCredentials: { method: 'api-key', apiKey: VALID_API_KEY }, invalidCredentials: { method: 'api-key', apiKey: 'wrong' }, createRuntime: async () => make(), createUnreachableRuntime: async () => make(createFixtureServer({ offline: true }))})The harness fields:
| Field | Meaning |
|---|---|
fixture |
Facts about your test data: a root container, a nested container and its expected path, an item and the container it is in, a word that finds that item, and an id that does not exist. |
validCredentials, invalidCredentials |
What connect should accept and reject. |
createRuntime |
A fresh, not-yet-connected runtime wired to a fake server. |
createUnreachableRuntime |
A runtime whose server cannot be reached. |
What it checks
Section titled “What it checks”This list is generated from the suite’s source, so it is always current:
- connects with valid credentials and reports connected
- rejects invalid credentials with authentication-failed
- returns normalized containers with root-to-leaf paths
- getContainer resolves a container by remote id
- returns normalized items
- container contents resolve to items that point back at the container
- unknown ids reject with not-found
- search results resolve to physical containers
- search honours containerIds and kind filters
- network failures become normalized errors
- operations before connect fail with a normalized error
- plugin-specific DTOs never escape
- disconnect succeeds and is idempotent
Search checks are skipped for plugins that do not implement the optional search.
Beyond the contract
Section titled “Beyond the contract”The contract covers what every plugin must do. Add your own tests for what is specific to yours: where credentials travel (a header, never a URL), how a revoked key differs from a wrong one, pagination, deep links and unusual remote data. The example plugin’s tests are a good model.
The suite’s source
Section titled “The suite’s source”import { describe, expect, test } from 'vitest'import { isIntegrationError } from '../errors'import type { InventoryIntegrationRuntime } from '../plugin'
/** Recorded/fixture knowledge about the dataset the runtime under test serves. */export interface ContractFixture { rootContainerRemoteId: string nestedContainerRemoteId: string expectedPath: string[] itemRemoteId: string itemContainerRemoteId: string searchText: string missingRemoteId: string}
export interface ContractHarness<TCredentials> { name: string fixture: ContractFixture validCredentials: TCredentials invalidCredentials: TCredentials /** A fresh, not-yet-connected runtime. */ createRuntime(): Promise<InventoryIntegrationRuntime<TCredentials>> /** A runtime whose server cannot be reached. */ createUnreachableRuntime(): Promise<InventoryIntegrationRuntime<TCredentials>>}
const CONTAINER_KEYS = new Set([ 'id', 'integrationInstanceId', 'remoteId', 'name', 'description', 'parentId', 'path', 'itemCount', 'imageUrl', 'sourceUrl', 'metadata', 'updatedAt', 'syncedAt'])const ITEM_KEYS = new Set([ 'id', 'integrationInstanceId', 'remoteId', 'name', 'description', 'quantity', 'quantityUnit', 'containerId', 'imageUrl', 'sourceUrl', 'sku', 'serialNumber', 'barcode', 'tags', 'metadata', 'updatedAt', 'syncedAt'])
function expectOnlyKeys(value: object, allowed: Set<string>) { for (const key of Object.keys(value)) expect(allowed, `unexpected key "${key}"`).toContain(key)}
/** Metadata must be plain data: it survives a JSON round-trip unchanged (no DTO class instances). */function expectPlainData(value: unknown) { expect(JSON.parse(JSON.stringify(value))).toEqual(value)}
async function connected<T>(h: ContractHarness<T>) { const runtime = await h.createRuntime() await runtime.connect(h.validCredentials) return runtime}
/** Every plugin must pass this same behavioural contract: call this from your plugin's test file with a harness. */export function integrationContractTests<TCredentials>(harness: ContractHarness<TCredentials>) { const { fixture } = harness
describe(`integration contract: ${harness.name}`, () => { test('connects with valid credentials and reports connected', async () => { const runtime = await harness.createRuntime() const result = await runtime.connect(harness.validCredentials) expect(result.status.state).toBe('connected') expect((await runtime.testConnection()).state).toBe('connected') })
test('rejects invalid credentials with authentication-failed', async () => { const runtime = await harness.createRuntime() await expect(runtime.connect(harness.invalidCredentials)).rejects.toMatchObject({ code: 'authentication-failed' }) })
test('returns normalized containers with root-to-leaf paths', async () => { const runtime = await connected(harness) const containers = await runtime.getContainers() expect(containers.length).toBeGreaterThan(0) for (const c of containers) { expectOnlyKeys(c, CONTAINER_KEYS) expect(c.id).toBeTruthy() expect(c.remoteId).toBeTruthy() expect(c.path.at(-1)).toBe(c.name) expect(Number.isNaN(Date.parse(c.syncedAt))).toBe(false) expectPlainData(c.metadata) } const nested = containers.find(c => c.remoteId === fixture.nestedContainerRemoteId)! expect(nested.path).toEqual(fixture.expectedPath) const parent = containers.find(c => c.id === nested.parentId) expect(parent?.path).toEqual(fixture.expectedPath.slice(0, -1)) })
test('getContainer resolves a container by remote id', async () => { const runtime = await connected(harness) const c = await runtime.getContainer(fixture.rootContainerRemoteId) expect(c.remoteId).toBe(fixture.rootContainerRemoteId) })
test('returns normalized items', async () => { const runtime = await connected(harness) const item = await runtime.getItem(fixture.itemRemoteId) expectOnlyKeys(item, ITEM_KEYS) expect(item.remoteId).toBe(fixture.itemRemoteId) expect(item.name).toBeTruthy() expectPlainData(item.metadata) })
test('container contents resolve to items that point back at the container', async () => { const runtime = await connected(harness) const container = await runtime.getContainer(fixture.itemContainerRemoteId) const contents = await runtime.getContainerContents(fixture.itemContainerRemoteId) expect(contents.map(i => i.remoteId)).toContain(fixture.itemRemoteId) for (const item of contents) { expectOnlyKeys(item, ITEM_KEYS) expect(item.containerId).toBe(container.id) } })
test('unknown ids reject with not-found', async () => { const runtime = await connected(harness) await expect(runtime.getContainer(fixture.missingRemoteId)).rejects.toMatchObject({ code: 'not-found' }) await expect(runtime.getItem(fixture.missingRemoteId)).rejects.toMatchObject({ code: 'not-found' }) })
test('search results resolve to physical containers', async () => { const runtime = await connected(harness) if (!runtime.search) return // capability is optional const results = await runtime.search({ text: fixture.searchText }) const hit = results.find(r => r.kind === 'item' && r.item.remoteId === fixture.itemRemoteId) expect(hit, 'expected the fixture item among results').toBeDefined() for (const r of results) { expect(r.score).toBeGreaterThanOrEqual(0) expect(r.searchableText).toBeTruthy() if (r.kind === 'item' && r.canLocatePhysically) { expect(r.container, 'locatable item results must carry their container').toBeDefined() expect(r.item.containerId).toBe(r.container!.id) } } const limited = await runtime.search({ text: fixture.searchText, limit: 1 }) expect(limited.length).toBeLessThanOrEqual(1) })
test('search honours containerIds and kind filters', async () => { const runtime = await connected(harness) if (!runtime.search) return const container = await runtime.getContainer(fixture.itemContainerRemoteId) const scoped = await runtime.search({ text: fixture.searchText, containerIds: [container.id], includeContainers: false }) expect(scoped.some(r => r.kind === 'item' && r.item.remoteId === fixture.itemRemoteId)).toBe(true) for (const r of scoped) { expect(r.kind).toBe('item') if (r.kind === 'item') expect(r.item.containerId).toBe(container.id) } expect(await runtime.search({ text: fixture.searchText, containerIds: ['no-such-container'] })).toEqual([]) const itemsOnly = await runtime.search({ text: fixture.searchText, includeContainers: false }) expect(itemsOnly.every(r => r.kind === 'item')).toBe(true) })
test('network failures become normalized errors', async () => { const runtime = await harness.createUnreachableRuntime() const err = await runtime.connect(harness.validCredentials).then(() => null, (e: unknown) => e) expect(isIntegrationError(err)).toBe(true) expect(err).toMatchObject({ code: 'network-unreachable', retryable: true }) })
test('operations before connect fail with a normalized error', async () => { const runtime = await harness.createRuntime() const err = await runtime.getContainers().then(() => null, (e: unknown) => e) expect(isIntegrationError(err)).toBe(true) })
test('plugin-specific DTOs never escape', async () => { const runtime = await connected(harness) const containers = await runtime.getContainers() const items = await runtime.getContainerContents(fixture.itemContainerRemoteId) for (const value of [...containers, ...items]) { expect(Object.getPrototypeOf(value)).toBe(Object.prototype) expectPlainData(value) } })
test('disconnect succeeds and is idempotent', async () => { const runtime = await connected(harness) await runtime.disconnect() await runtime.disconnect() }) })}