From c773bea9da1b76231cfe39593a4df7312cd431b7 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Wed, 25 Feb 2026 12:07:33 +0000 Subject: [PATCH 1/2] feat(tests): add integration test suites for all DB repositories --- src/db/migrations/0000_base_schema.sql | 62 ++ src/db/migrations/meta/_journal.json | 35 +- tests/integration/db/configRepository.test.ts | 275 +++++++++ .../db/credentialResolution.test.ts | 202 +++++++ .../integration/db/partialsRepository.test.ts | 196 +++++++ .../db/prWorkItemsRepository.test.ts | 107 ++++ tests/integration/db/runsRepository.test.ts | 533 ++++++++++++++++++ .../integration/db/settingsRepository.test.ts | 444 +++++++++++++++ tests/integration/db/usersRepository.test.ts | 166 ++++++ .../db/webhookLogsRepository.test.ts | 210 +++++++ tests/integration/helpers/seed.ts | 231 ++++++++ 11 files changed, 2447 insertions(+), 14 deletions(-) create mode 100644 src/db/migrations/0000_base_schema.sql create mode 100644 tests/integration/db/configRepository.test.ts create mode 100644 tests/integration/db/credentialResolution.test.ts create mode 100644 tests/integration/db/partialsRepository.test.ts create mode 100644 tests/integration/db/prWorkItemsRepository.test.ts create mode 100644 tests/integration/db/runsRepository.test.ts create mode 100644 tests/integration/db/settingsRepository.test.ts create mode 100644 tests/integration/db/usersRepository.test.ts create mode 100644 tests/integration/db/webhookLogsRepository.test.ts diff --git a/src/db/migrations/0000_base_schema.sql b/src/db/migrations/0000_base_schema.sql new file mode 100644 index 00000000..38335eb4 --- /dev/null +++ b/src/db/migrations/0000_base_schema.sql @@ -0,0 +1,62 @@ +-- Base Schema +-- Creates the initial tables needed before the incremental migration chain (0001+). +-- This migration is only applied to fresh databases. + +BEGIN; + +-- Projects (original schema before 0001) +CREATE TABLE IF NOT EXISTS "projects" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "repo" text NOT NULL, + "base_branch" text, + "branch_prefix" text, + "model" text, + "card_budget_usd" numeric(10, 2), + "agent_backend_default" text, + "github_token_env" text, + "reviewer_token_env" text, + "trello_board_id" text, + "trello_lists" jsonb, + "trello_labels" jsonb, + "trello_custom_fields" jsonb, + "triggers" jsonb, + "agent_models" jsonb, + "agent_backend_overrides" jsonb, + "prompts" jsonb, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS "idx_projects_repo" ON "projects" ("repo"); +CREATE INDEX IF NOT EXISTS "idx_projects_trello_board_id" ON "projects" ("trello_board_id"); + +-- Project secrets (original credential storage, replaced in 0003) +CREATE TABLE IF NOT EXISTS "project_secrets" ( + "id" serial PRIMARY KEY NOT NULL, + "project_id" text NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "key" text NOT NULL, + "value" text NOT NULL, + "created_at" timestamp DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS "uq_project_secrets_project_key" + ON "project_secrets" ("project_id", "key"); + +-- Cascade defaults (original schema before 0005) +CREATE TABLE IF NOT EXISTS "cascade_defaults" ( + "id" serial PRIMARY KEY NOT NULL, + "model" text, + "max_iterations" integer, + "watchdog_timeout_ms" integer, + "card_budget_usd" numeric(10, 2), + "agent_backend" text, + "progress_model" text, + "progress_interval_minutes" numeric(5, 1), + "agent_models" jsonb, + "agent_iterations" jsonb, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); + +COMMIT; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index eeee30db..9dd14544 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -5,103 +5,110 @@ { "idx": 0, "version": "7", + "when": 1735000000000, + "tag": "0000_base_schema", + "breakpoints": false + }, + { + "idx": 1, + "version": "7", "when": 1736000000000, "tag": "0001_three_tier_normalization", "breakpoints": false }, { - "idx": 1, + "idx": 2, "version": "7", "when": 1737000000000, "tag": "0002_agent_run_tracking", "breakpoints": false }, { - "idx": 2, + "idx": 3, "version": "7", "when": 1738000000000, "tag": "0003_organizations_and_credentials", "breakpoints": false }, { - "idx": 3, + "idx": 4, "version": "7", "when": 1739000000000, "tag": "0004_agent_credential_overrides", "breakpoints": false }, { - "idx": 4, + "idx": 5, "version": "7", "when": 1740000000000, "tag": "0005_config_schema_cleanup", "breakpoints": false }, { - "idx": 5, + "idx": 6, "version": "7", "when": 1741000000000, "tag": "0006_users_and_sessions", "breakpoints": false }, { - "idx": 6, + "idx": 7, "version": "7", "when": 1742000000000, "tag": "0007_remove_flyio_columns", "breakpoints": false }, { - "idx": 7, + "idx": 8, "version": "7", "when": 1743000000000, "tag": "0008_prompt_partials", "breakpoints": false }, { - "idx": 8, + "idx": 9, "version": "7", "when": 1744000000000, "tag": "0009_add_squint_db_url", "breakpoints": false }, { - "idx": 9, + "idx": 10, "version": "7", "when": 1745000000000, "tag": "0010_webhook_logs", "breakpoints": false }, { - "idx": 10, + "idx": 11, "version": "7", "when": 1746000000000, "tag": "0011_remove_credentials_description", "breakpoints": false }, { - "idx": 11, + "idx": 12, "version": "7", "when": 1747000000000, "tag": "0012_llm_calls_realtime", "breakpoints": false }, { - "idx": 12, + "idx": 13, "version": "7", "when": 1748000000000, "tag": "0013_integration_model_refactor", "breakpoints": false }, { - "idx": 13, + "idx": 14, "version": "7", "when": 1749000000000, "tag": "0014_pr_work_items", "breakpoints": false }, { - "idx": 14, + "idx": 15, "version": "7", "when": 1750000000000, "tag": "0015_rename_briefing_to_splitting", diff --git a/tests/integration/db/configRepository.test.ts b/tests/integration/db/configRepository.test.ts new file mode 100644 index 00000000..112c496a --- /dev/null +++ b/tests/integration/db/configRepository.test.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + findProjectByBoardIdFromDb, + findProjectByIdFromDb, + findProjectByJiraProjectKeyFromDb, + findProjectByRepoFromDb, + findProjectWithConfigByBoardId, + loadConfigFromDb, +} from '../../../src/db/repositories/configRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { + seedAgentConfig, + seedDefaults, + seedIntegration, + seedOrg, + seedProject, +} from '../helpers/seed.js'; + +describe('configRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // loadConfigFromDb + // ========================================================================= + + describe('loadConfigFromDb', () => { + it('returns a valid CascadeConfig with no data beyond org+project', async () => { + const config = await loadConfigFromDb(); + expect(config).toBeDefined(); + expect(config.projects).toHaveLength(1); + expect(config.projects[0].id).toBe('test-project'); + }); + + it('includes defaults when cascade_defaults row exists', async () => { + await seedDefaults({ model: 'claude-opus-4-5', maxIterations: 30 }); + const config = await loadConfigFromDb(); + expect(config.defaults.model).toBe('claude-opus-4-5'); + expect(config.defaults.maxIterations).toBe(30); + }); + + it('includes trello integration config in project', async () => { + await seedIntegration({ + category: 'pm', + provider: 'trello', + config: { boardId: 'board-123', lists: {}, labels: {} }, + }); + const config = await loadConfigFromDb(); + const project = config.projects[0]; + expect(project.trello?.boardId).toBe('board-123'); + }); + + it('handles multiple projects', async () => { + await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); + const config = await loadConfigFromDb(); + expect(config.projects).toHaveLength(2); + expect(config.projects.map((p) => p.id).sort()).toEqual(['project-2', 'test-project']); + }); + + it('applies global agent config model overrides to defaults.agentModels', async () => { + await seedDefaults(); + await seedAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'global-impl-model', + }); + const config = await loadConfigFromDb(); + expect(config.defaults.agentModels.implementation).toBe('global-impl-model'); + }); + + it('applies global agent config iteration overrides to defaults.agentIterations', async () => { + await seedDefaults(); + await seedAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + maxIterations: 25, + }); + const config = await loadConfigFromDb(); + expect(config.defaults.agentIterations.implementation).toBe(25); + }); + + it('applies org-level agent config overrides to defaults.agentModels', async () => { + await seedDefaults(); + await seedAgentConfig({ + orgId: 'test-org', + projectId: null, + agentType: 'review', + model: 'org-review-model', + }); + const config = await loadConfigFromDb(); + expect(config.defaults.agentModels.review).toBe('org-review-model'); + }); + + it('applies project-level agent config overrides to project.agentModels', async () => { + await seedAgentConfig({ + orgId: null, + projectId: 'test-project', + agentType: 'implementation', + model: 'project-impl-model', + }); + const config = await loadConfigFromDb(); + const project = config.projects[0]; + expect(project.agentModels?.implementation).toBe('project-impl-model'); + }); + }); + + // ========================================================================= + // findProjectByBoardIdFromDb + // ========================================================================= + + describe('findProjectByBoardIdFromDb', () => { + it('finds a project by its Trello board ID', async () => { + await seedIntegration({ + category: 'pm', + provider: 'trello', + config: { boardId: 'board-abc', lists: {}, labels: {} }, + }); + const project = await findProjectByBoardIdFromDb('board-abc'); + expect(project).toBeDefined(); + expect(project?.id).toBe('test-project'); + }); + + it('returns undefined for non-existent board ID', async () => { + const project = await findProjectByBoardIdFromDb('nonexistent-board'); + expect(project).toBeUndefined(); + }); + }); + + // ========================================================================= + // findProjectByRepoFromDb + // ========================================================================= + + describe('findProjectByRepoFromDb', () => { + it('finds a project by its repo', async () => { + const project = await findProjectByRepoFromDb('owner/repo'); + expect(project).toBeDefined(); + expect(project?.id).toBe('test-project'); + }); + + it('returns undefined for non-existent repo', async () => { + const project = await findProjectByRepoFromDb('nonexistent/repo'); + expect(project).toBeUndefined(); + }); + }); + + // ========================================================================= + // findProjectByIdFromDb + // ========================================================================= + + describe('findProjectByIdFromDb', () => { + it('finds a project by its ID', async () => { + const project = await findProjectByIdFromDb('test-project'); + expect(project).toBeDefined(); + expect(project?.id).toBe('test-project'); + expect(project?.orgId).toBe('test-org'); + }); + + it('returns undefined for non-existent ID', async () => { + const project = await findProjectByIdFromDb('nonexistent-project'); + expect(project).toBeUndefined(); + }); + }); + + // ========================================================================= + // findProjectByJiraProjectKeyFromDb + // ========================================================================= + + describe('findProjectByJiraProjectKeyFromDb', () => { + it('finds a project by its JIRA project key', async () => { + await seedIntegration({ + category: 'pm', + provider: 'jira', + config: { + projectKey: 'PROJ', + baseUrl: 'https://example.atlassian.net', + statuses: { splitting: 'Splitting', todo: 'To Do' }, + }, + }); + const project = await findProjectByJiraProjectKeyFromDb('PROJ'); + expect(project).toBeDefined(); + expect(project?.id).toBe('test-project'); + }); + + it('returns undefined for non-existent JIRA project key', async () => { + const project = await findProjectByJiraProjectKeyFromDb('NONEXISTENT'); + expect(project).toBeUndefined(); + }); + }); + + // ========================================================================= + // findProjectWithConfigByBoardId + // ========================================================================= + + describe('findProjectWithConfigByBoardId', () => { + it('returns both project and config', async () => { + await seedDefaults({ model: 'claude-sonnet' }); + await seedIntegration({ + category: 'pm', + provider: 'trello', + config: { boardId: 'board-xyz', lists: {}, labels: {} }, + }); + const result = await findProjectWithConfigByBoardId('board-xyz'); + expect(result).toBeDefined(); + expect(result?.project.id).toBe('test-project'); + expect(result?.config).toBeDefined(); + expect(result?.config.defaults.model).toBe('claude-sonnet'); + }); + + it('returns undefined for non-existent board', async () => { + const result = await findProjectWithConfigByBoardId('no-such-board'); + expect(result).toBeUndefined(); + }); + }); + + // ========================================================================= + // Agent config inheritance: global → org → project + // ========================================================================= + + describe('agent config inheritance', () => { + it('project agentModels overrides global agentModels for the same agent', async () => { + await seedDefaults(); + await seedAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'global-model', + }); + await seedAgentConfig({ + orgId: null, + projectId: 'test-project', + agentType: 'implementation', + model: 'project-model', + }); + const config = await loadConfigFromDb(); + const project = config.projects[0]; + // Project-level agentModels should take precedence + expect(project.agentModels?.implementation).toBe('project-model'); + // Global-level should be in defaults + expect(config.defaults.agentModels.implementation).toBe('global-model'); + }); + }); + + // ========================================================================= + // Multi-project config loading + // ========================================================================= + + describe('multi-project config loading', () => { + it('correctly loads integrations for each project separately', async () => { + await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); + await seedIntegration({ + projectId: 'test-project', + category: 'pm', + provider: 'trello', + config: { boardId: 'board-project-1', lists: {}, labels: {} }, + }); + await seedIntegration({ + projectId: 'project-2', + category: 'pm', + provider: 'trello', + config: { boardId: 'board-project-2', lists: {}, labels: {} }, + }); + const config = await loadConfigFromDb(); + expect(config.projects).toHaveLength(2); + const p1 = config.projects.find((p) => p.id === 'test-project'); + const p2 = config.projects.find((p) => p.id === 'project-2'); + expect(p1?.trello?.boardId).toBe('board-project-1'); + expect(p2?.trello?.boardId).toBe('board-project-2'); + }); + }); +}); diff --git a/tests/integration/db/credentialResolution.test.ts b/tests/integration/db/credentialResolution.test.ts new file mode 100644 index 00000000..ca8c9f70 --- /dev/null +++ b/tests/integration/db/credentialResolution.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAllProjectCredentials } from '../../../src/config/provider.js'; +import { createCredential } from '../../../src/db/repositories/credentialsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { + seedCredential, + seedIntegration, + seedIntegrationCredential, + seedOrg, + seedProject, +} from '../helpers/seed.js'; + +describe('credentialResolution (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // getAllProjectCredentials — end-to-end + // ========================================================================= + + describe('getAllProjectCredentials', () => { + it('returns empty object when no credentials configured', async () => { + const creds = await getAllProjectCredentials('test-project'); + expect(creds).toEqual({}); + }); + + it('includes default org credentials (LLM API keys)', async () => { + await seedCredential({ + orgId: 'test-org', + envVarKey: 'OPENROUTER_API_KEY', + value: 'or-key-secret', + isDefault: true, + }); + + const creds = await getAllProjectCredentials('test-project'); + expect(creds.OPENROUTER_API_KEY).toBe('or-key-secret'); + }); + + it('excludes non-default org credentials', async () => { + await seedCredential({ + orgId: 'test-org', + envVarKey: 'NON_DEFAULT_KEY', + value: 'should-not-appear', + isDefault: false, + }); + + const creds = await getAllProjectCredentials('test-project'); + expect(creds.NON_DEFAULT_KEY).toBeUndefined(); + }); + + it('includes integration credentials mapped to env var keys', async () => { + const apiKeyCred = await seedCredential({ + envVarKey: 'TRELLO_API_KEY', + value: 'trello-api-key-value', + }); + const tokenCred = await seedCredential({ + envVarKey: 'TRELLO_TOKEN', + value: 'trello-token-value', + name: 'Trello Token', + }); + const integration = await seedIntegration({ category: 'pm', provider: 'trello' }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'api_key', + credentialId: apiKeyCred.id, + }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'token', + credentialId: tokenCred.id, + }); + + const creds = await getAllProjectCredentials('test-project'); + expect(creds.TRELLO_API_KEY).toBe('trello-api-key-value'); + expect(creds.TRELLO_TOKEN).toBe('trello-token-value'); + }); + + it('integration credentials override org default credentials', async () => { + // Set up a default org credential for GITHUB_TOKEN_IMPLEMENTER + await seedCredential({ + orgId: 'test-org', + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + value: 'default-token', + isDefault: true, + }); + + // Set up a project-specific integration credential + const specificCred = await seedCredential({ + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + value: 'specific-token', + name: 'Specific Implementer Token', + }); + const integration = await seedIntegration({ category: 'scm', provider: 'github' }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'implementer_token', + credentialId: specificCred.id, + }); + + const creds = await getAllProjectCredentials('test-project'); + // Integration credential should override org default + expect(creds.GITHUB_TOKEN_IMPLEMENTER).toBe('specific-token'); + }); + + it('includes both org defaults and integration credentials merged', async () => { + // Org default for LLM + await seedCredential({ + orgId: 'test-org', + envVarKey: 'OPENROUTER_API_KEY', + value: 'llm-key', + isDefault: true, + }); + + // Integration credentials for SCM + const ghCred = await seedCredential({ + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + value: 'gh-impl-token', + name: 'GH Implementer', + }); + const integration = await seedIntegration({ category: 'scm', provider: 'github' }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'implementer_token', + credentialId: ghCred.id, + }); + + const creds = await getAllProjectCredentials('test-project'); + expect(creds.OPENROUTER_API_KEY).toBe('llm-key'); + expect(creds.GITHUB_TOKEN_IMPLEMENTER).toBe('gh-impl-token'); + }); + + it('throws when project not found', async () => { + await expect(getAllProjectCredentials('nonexistent-project')).rejects.toThrow( + 'Project not found: nonexistent-project', + ); + }); + }); + + // ========================================================================= + // Encryption round-trip + // ========================================================================= + + describe('with encryption', () => { + it('round-trips credentials through encrypt/decrypt transparently', async () => { + // 64-char hex = 32-byte AES-256 key + vi.stubEnv('CREDENTIAL_MASTER_KEY', 'b'.repeat(64)); + + const { id } = await createCredential({ + orgId: 'test-org', + name: 'Encrypted LLM Key', + envVarKey: 'OPENROUTER_API_KEY', + value: 'plaintext-llm-secret', + isDefault: true, + }); + + expect(id).toBeGreaterThan(0); + + // getAllProjectCredentials should transparently decrypt + const creds = await getAllProjectCredentials('test-project'); + expect(creds.OPENROUTER_API_KEY).toBe('plaintext-llm-secret'); + }); + + it('round-trips integration credentials through encrypt/decrypt', async () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', 'c'.repeat(64)); + + const cred = await createCredential({ + orgId: 'test-org', + name: 'Encrypted Trello Key', + envVarKey: 'TRELLO_API_KEY', + value: 'encrypted-api-key', + }); + const integration = await seedIntegration({ category: 'pm', provider: 'trello' }); + await seedIntegrationCredential({ + integrationId: integration.id, + role: 'api_key', + credentialId: cred.id, + }); + + const creds = await getAllProjectCredentials('test-project'); + expect(creds.TRELLO_API_KEY).toBe('encrypted-api-key'); + }); + }); + + // ========================================================================= + // Worker context + // ========================================================================= + + describe('worker context (CASCADE_CREDENTIAL_KEYS set)', () => { + it('returns credentials from process.env when CASCADE_CREDENTIAL_KEYS is set', async () => { + vi.stubEnv('CASCADE_CREDENTIAL_KEYS', 'OPENROUTER_API_KEY,GITHUB_TOKEN_IMPLEMENTER'); + vi.stubEnv('OPENROUTER_API_KEY', 'env-llm-key'); + vi.stubEnv('GITHUB_TOKEN_IMPLEMENTER', 'env-gh-token'); + + const creds = await getAllProjectCredentials('test-project'); + expect(creds.OPENROUTER_API_KEY).toBe('env-llm-key'); + expect(creds.GITHUB_TOKEN_IMPLEMENTER).toBe('env-gh-token'); + }); + }); +}); diff --git a/tests/integration/db/partialsRepository.test.ts b/tests/integration/db/partialsRepository.test.ts new file mode 100644 index 00000000..847cf298 --- /dev/null +++ b/tests/integration/db/partialsRepository.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + deletePartial, + getPartial, + listPartials, + loadPartials, + upsertPartial, +} from '../../../src/db/repositories/partialsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedOrg, seedProject, seedPromptPartial } from '../helpers/seed.js'; + +describe('partialsRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // loadPartials + // ========================================================================= + + describe('loadPartials', () => { + it('returns empty map when no partials exist', async () => { + const partials = await loadPartials(); + expect(partials.size).toBe(0); + }); + + it('returns global partials only when no orgId given', async () => { + await seedPromptPartial({ orgId: null, name: 'global-partial', content: 'Global content' }); + await seedPromptPartial({ orgId: 'test-org', name: 'org-partial', content: 'Org content' }); + + const partials = await loadPartials(); + expect(partials.has('global-partial')).toBe(true); + expect(partials.has('org-partial')).toBe(false); + }); + + it('returns global partials when orgId given', async () => { + await seedPromptPartial({ orgId: null, name: 'global-partial', content: 'Global content' }); + + const partials = await loadPartials('test-org'); + expect(partials.has('global-partial')).toBe(true); + }); + + it('org partials overlay global partials with the same name', async () => { + await seedPromptPartial({ orgId: null, name: 'shared-partial', content: 'Global version' }); + await seedPromptPartial({ + orgId: 'test-org', + name: 'shared-partial', + content: 'Org version', + }); + + const partials = await loadPartials('test-org'); + expect(partials.get('shared-partial')).toBe('Org version'); + }); + + it('includes org-specific partials not in global', async () => { + await seedPromptPartial({ orgId: null, name: 'global-only', content: 'Global only' }); + await seedPromptPartial({ orgId: 'test-org', name: 'org-only', content: 'Org only' }); + + const partials = await loadPartials('test-org'); + expect(partials.has('global-only')).toBe(true); + expect(partials.has('org-only')).toBe(true); + expect(partials.size).toBe(2); + }); + }); + + // ========================================================================= + // listPartials + // ========================================================================= + + describe('listPartials', () => { + it('returns only global partials when no orgId given', async () => { + await seedPromptPartial({ orgId: null, name: 'global-p', content: 'global' }); + await seedPromptPartial({ orgId: 'test-org', name: 'org-p', content: 'org' }); + + const partials = await listPartials(); + expect(partials.every((p) => p.orgId === null)).toBe(true); + expect(partials.some((p) => p.name === 'global-p')).toBe(true); + }); + + it('returns both global and org-scoped partials when orgId given', async () => { + await seedPromptPartial({ orgId: null, name: 'global-p', content: 'global' }); + await seedPromptPartial({ orgId: 'test-org', name: 'org-p', content: 'org' }); + + const partials = await listPartials('test-org'); + expect(partials.some((p) => p.name === 'global-p')).toBe(true); + expect(partials.some((p) => p.name === 'org-p')).toBe(true); + }); + }); + + // ========================================================================= + // getPartial + // ========================================================================= + + describe('getPartial', () => { + it('returns global partial when found', async () => { + await seedPromptPartial({ orgId: null, name: 'my-partial', content: 'my content' }); + + const partial = await getPartial('my-partial'); + expect(partial).toBeDefined(); + expect(partial?.content).toBe('my content'); + }); + + it('returns null when partial not found', async () => { + const partial = await getPartial('nonexistent'); + expect(partial).toBeNull(); + }); + + it('returns org-scoped partial with priority over global', async () => { + await seedPromptPartial({ orgId: null, name: 'shared', content: 'global content' }); + await seedPromptPartial({ orgId: 'test-org', name: 'shared', content: 'org content' }); + + const partial = await getPartial('shared', 'test-org'); + expect(partial?.content).toBe('org content'); + }); + + it('falls back to global partial when org-scoped one not found', async () => { + await seedPromptPartial({ orgId: null, name: 'global-only', content: 'global content' }); + + const partial = await getPartial('global-only', 'test-org'); + expect(partial?.content).toBe('global content'); + }); + }); + + // ========================================================================= + // upsertPartial + // ========================================================================= + + describe('upsertPartial', () => { + it('inserts a new global partial', async () => { + const partial = await upsertPartial({ + orgId: null, + name: 'new-partial', + content: 'new content', + }); + expect(partial.name).toBe('new-partial'); + expect(partial.content).toBe('new content'); + expect(partial.orgId).toBeNull(); + }); + + it('inserts a new org-scoped partial', async () => { + const partial = await upsertPartial({ + orgId: 'test-org', + name: 'org-partial', + content: 'org content', + }); + expect(partial.orgId).toBe('test-org'); + }); + + it('updates an existing partial without creating a duplicate', async () => { + await upsertPartial({ orgId: null, name: 'dup-test', content: 'original' }); + await upsertPartial({ orgId: null, name: 'dup-test', content: 'updated' }); + + const allPartials = await listPartials(); + const matches = allPartials.filter((p) => p.name === 'dup-test'); + expect(matches).toHaveLength(1); + expect(matches[0].content).toBe('updated'); + }); + + it('updates an org-scoped partial', async () => { + await upsertPartial({ orgId: 'test-org', name: 'org-dup', content: 'v1' }); + const updated = await upsertPartial({ orgId: 'test-org', name: 'org-dup', content: 'v2' }); + expect(updated.content).toBe('v2'); + }); + }); + + // ========================================================================= + // deletePartial + // ========================================================================= + + describe('deletePartial', () => { + it('deletes a partial by ID', async () => { + const partial = await upsertPartial({ orgId: null, name: 'to-delete', content: 'delete me' }); + await deletePartial(partial.id); + + const found = await getPartial('to-delete'); + expect(found).toBeNull(); + }); + + it('deletes org-scoped partial without affecting global with same name', async () => { + await seedPromptPartial({ orgId: null, name: 'keep-global', content: 'global' }); + const orgPartial = await upsertPartial({ + orgId: 'test-org', + name: 'keep-global', + content: 'org', + }); + + await deletePartial(orgPartial.id); + + // Global still exists + const remaining = await getPartial('keep-global'); + expect(remaining?.content).toBe('global'); + }); + }); +}); diff --git a/tests/integration/db/prWorkItemsRepository.test.ts b/tests/integration/db/prWorkItemsRepository.test.ts new file mode 100644 index 00000000..e39dc419 --- /dev/null +++ b/tests/integration/db/prWorkItemsRepository.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + linkPRToWorkItem, + lookupWorkItemForPR, +} from '../../../src/db/repositories/prWorkItemsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedOrg, seedProject } from '../helpers/seed.js'; + +describe('prWorkItemsRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // linkPRToWorkItem / lookupWorkItemForPR + // ========================================================================= + + describe('linkPRToWorkItem', () => { + it('links a PR to a work item', async () => { + await linkPRToWorkItem('test-project', 'owner/repo', 42, 'card-abc123'); + + const workItemId = await lookupWorkItemForPR('test-project', 42); + expect(workItemId).toBe('card-abc123'); + }); + + it('links multiple PRs to different work items', async () => { + await linkPRToWorkItem('test-project', 'owner/repo', 1, 'card-111'); + await linkPRToWorkItem('test-project', 'owner/repo', 2, 'card-222'); + + expect(await lookupWorkItemForPR('test-project', 1)).toBe('card-111'); + expect(await lookupWorkItemForPR('test-project', 2)).toBe('card-222'); + }); + }); + + describe('lookupWorkItemForPR', () => { + it('returns null for non-existent link', async () => { + const result = await lookupWorkItemForPR('test-project', 999); + expect(result).toBeNull(); + }); + + it('returns null for wrong project', async () => { + await linkPRToWorkItem('test-project', 'owner/repo', 10, 'card-xyz'); + + // Different project, same PR number + await seedProject({ id: 'other-project', repo: 'owner/other-repo' }); + const result = await lookupWorkItemForPR('other-project', 10); + expect(result).toBeNull(); + }); + }); + + // ========================================================================= + // Upsert behavior + // ========================================================================= + + describe('upsert (re-link same PR)', () => { + it('updates work item ID when same project+PR is re-linked', async () => { + await linkPRToWorkItem('test-project', 'owner/repo', 5, 'card-original'); + + // Re-link same PR to a different card + await linkPRToWorkItem('test-project', 'owner/repo', 5, 'card-updated'); + + const workItemId = await lookupWorkItemForPR('test-project', 5); + expect(workItemId).toBe('card-updated'); + }); + + it('updates repoFullName when re-linking', async () => { + await linkPRToWorkItem('test-project', 'owner/old-repo', 7, 'card-abc'); + await linkPRToWorkItem('test-project', 'owner/new-repo', 7, 'card-abc'); + + // Still resolvable by projectId+prNumber + const workItemId = await lookupWorkItemForPR('test-project', 7); + expect(workItemId).toBe('card-abc'); + }); + }); + + // ========================================================================= + // Cross-project isolation + // ========================================================================= + + describe('cross-project isolation', () => { + it('same PR number in different projects resolves to different work items', async () => { + await seedProject({ id: 'project-b', repo: 'owner/repo-b' }); + + await linkPRToWorkItem('test-project', 'owner/repo', 100, 'card-project-a'); + await linkPRToWorkItem('project-b', 'owner/repo-b', 100, 'card-project-b'); + + expect(await lookupWorkItemForPR('test-project', 100)).toBe('card-project-a'); + expect(await lookupWorkItemForPR('project-b', 100)).toBe('card-project-b'); + }); + + it('deleting one project link does not affect another', async () => { + await seedProject({ id: 'project-c', repo: 'owner/repo-c' }); + + await linkPRToWorkItem('test-project', 'owner/repo', 200, 'card-a'); + await linkPRToWorkItem('project-c', 'owner/repo-c', 200, 'card-c'); + + // Re-link project-c's PR to a new card (effectively "removing" the old link) + await linkPRToWorkItem('project-c', 'owner/repo-c', 200, 'card-c-new'); + + // test-project's link is unaffected + expect(await lookupWorkItemForPR('test-project', 200)).toBe('card-a'); + expect(await lookupWorkItemForPR('project-c', 200)).toBe('card-c-new'); + }); + }); +}); diff --git a/tests/integration/db/runsRepository.test.ts b/tests/integration/db/runsRepository.test.ts new file mode 100644 index 00000000..9fe330e2 --- /dev/null +++ b/tests/integration/db/runsRepository.test.ts @@ -0,0 +1,533 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + completeRun, + createRun, + deleteDebugAnalysisByRunId, + getDebugAnalysisByRunId, + getLlmCallByNumber, + getLlmCallsByRunId, + getRunById, + getRunLogs, + getRunsByCardId, + getRunsByProjectId, + listLlmCallsMeta, + listProjectsForOrg, + listRuns, + storeDebugAnalysis, + storeLlmCall, + storeLlmCallsBulk, + storeRunLogs, +} from '../../../src/db/repositories/runsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedOrg, seedProject, seedRun } from '../helpers/seed.js'; + +describe('runsRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // Run CRUD + // ========================================================================= + + describe('createRun', () => { + it('creates a run and returns its ID', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + expect(id).toBeTruthy(); + expect(typeof id).toBe('string'); + }); + + it('creates a run with optional fields', async () => { + const id = await createRun({ + projectId: 'test-project', + cardId: 'card-123', + prNumber: 42, + agentType: 'review', + backend: 'llmist', + triggerType: 'feature-implementation', + model: 'claude-opus-4-5', + maxIterations: 20, + }); + const run = await getRunById(id); + expect(run?.cardId).toBe('card-123'); + expect(run?.prNumber).toBe(42); + expect(run?.agentType).toBe('review'); + expect(run?.backend).toBe('llmist'); + expect(run?.model).toBe('claude-opus-4-5'); + expect(run?.maxIterations).toBe(20); + expect(run?.status).toBe('running'); + }); + }); + + describe('completeRun', () => { + it('marks a run as completed with metrics', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await completeRun(id, { + status: 'completed', + durationMs: 5000, + llmIterations: 10, + gadgetCalls: 25, + costUsd: 0.05, + success: true, + prUrl: 'https://github.com/owner/repo/pull/1', + outputSummary: 'Implemented feature X', + }); + + const run = await getRunById(id); + expect(run?.status).toBe('completed'); + expect(run?.durationMs).toBe(5000); + expect(run?.llmIterations).toBe(10); + expect(run?.gadgetCalls).toBe(25); + expect(run?.success).toBe(true); + expect(run?.prUrl).toBe('https://github.com/owner/repo/pull/1'); + expect(run?.completedAt).toBeDefined(); + }); + + it('marks a run as failed with error', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await completeRun(id, { + status: 'failed', + success: false, + error: 'Connection timeout', + }); + + const run = await getRunById(id); + expect(run?.status).toBe('failed'); + expect(run?.success).toBe(false); + expect(run?.error).toBe('Connection timeout'); + }); + }); + + describe('getRunById', () => { + it('returns the run', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + const run = await getRunById(id); + expect(run).toBeDefined(); + expect(run?.id).toBe(id); + }); + + it('returns null for non-existent ID', async () => { + const run = await getRunById('00000000-0000-0000-0000-000000000000'); + expect(run).toBeNull(); + }); + }); + + describe('getRunsByCardId', () => { + it('returns all runs for a card', async () => { + await createRun({ + projectId: 'test-project', + cardId: 'card-A', + agentType: 'implementation', + backend: 'claude-code', + }); + await createRun({ + projectId: 'test-project', + cardId: 'card-A', + agentType: 'review', + backend: 'claude-code', + }); + await createRun({ + projectId: 'test-project', + cardId: 'card-B', + agentType: 'implementation', + backend: 'claude-code', + }); + + const runs = await getRunsByCardId('card-A'); + expect(runs).toHaveLength(2); + expect(runs.every((r) => r.cardId === 'card-A')).toBe(true); + }); + + it('returns empty array for unknown card', async () => { + const runs = await getRunsByCardId('nonexistent-card'); + expect(runs).toEqual([]); + }); + }); + + describe('getRunsByProjectId', () => { + it('returns all runs for a project', async () => { + await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + await createRun({ projectId: 'test-project', agentType: 'review', backend: 'claude-code' }); + + const runs = await getRunsByProjectId('test-project'); + expect(runs).toHaveLength(2); + }); + }); + + // ========================================================================= + // Log Storage + // ========================================================================= + + describe('storeRunLogs / getRunLogs', () => { + it('stores and retrieves logs for a run', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await storeRunLogs(id, 'cascade log content', 'llmist log content'); + + const logs = await getRunLogs(id); + expect(logs?.cascadeLog).toBe('cascade log content'); + expect(logs?.llmistLog).toBe('llmist log content'); + }); + + it('returns null for run with no logs', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + const logs = await getRunLogs(id); + expect(logs).toBeNull(); + }); + }); + + // ========================================================================= + // LLM Calls + // ========================================================================= + + describe('storeLlmCall / getLlmCallsByRunId', () => { + it('stores and retrieves an LLM call', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await storeLlmCall({ + runId: id, + callNumber: 1, + request: '{"messages":[]}', + response: '{"content":"hello"}', + inputTokens: 100, + outputTokens: 50, + costUsd: 0.001, + durationMs: 500, + model: 'claude-opus-4-5', + }); + + const calls = await getLlmCallsByRunId(id); + expect(calls).toHaveLength(1); + expect(calls[0].callNumber).toBe(1); + expect(calls[0].inputTokens).toBe(100); + expect(calls[0].outputTokens).toBe(50); + expect(calls[0].model).toBe('claude-opus-4-5'); + }); + }); + + describe('storeLlmCallsBulk', () => { + it('stores multiple LLM calls at once', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await storeLlmCallsBulk([ + { + runId: id, + callNumber: 1, + model: 'model-1', + inputTokens: 10, + outputTokens: 5, + costUsd: 0.001, + }, + { + runId: id, + callNumber: 2, + model: 'model-2', + inputTokens: 20, + outputTokens: 10, + costUsd: 0.002, + }, + { + runId: id, + callNumber: 3, + model: 'model-3', + inputTokens: 30, + outputTokens: 15, + costUsd: 0.003, + }, + ]); + + const calls = await getLlmCallsByRunId(id); + expect(calls).toHaveLength(3); + expect(calls.map((c) => c.callNumber)).toEqual([1, 2, 3]); + }); + + it('does nothing when given empty array', async () => { + await expect(storeLlmCallsBulk([])).resolves.toBeUndefined(); + }); + }); + + describe('getLlmCallByNumber', () => { + it('returns a specific call by number', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await storeLlmCallsBulk([ + { runId: id, callNumber: 1, model: 'model-1' }, + { runId: id, callNumber: 2, model: 'model-2' }, + ]); + + const call = await getLlmCallByNumber(id, 2); + expect(call).toBeDefined(); + expect(call?.callNumber).toBe(2); + expect(call?.model).toBe('model-2'); + }); + + it('returns null for non-existent call number', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + const call = await getLlmCallByNumber(id, 99); + expect(call).toBeNull(); + }); + }); + + describe('listLlmCallsMeta', () => { + it('returns calls metadata without request/response bodies', async () => { + const id = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await storeLlmCall({ + runId: id, + callNumber: 1, + request: 'big request body', + response: 'big response body', + inputTokens: 100, + outputTokens: 50, + model: 'claude-opus-4-5', + }); + + const meta = await listLlmCallsMeta(id); + expect(meta).toHaveLength(1); + expect(meta[0].inputTokens).toBe(100); + // listLlmCallsMeta does not return request/response + expect('request' in meta[0]).toBe(false); + expect('response' in meta[0]).toBe(false); + }); + }); + + // ========================================================================= + // Debug Analysis + // ========================================================================= + + describe('storeDebugAnalysis / getDebugAnalysisByRunId / deleteDebugAnalysisByRunId', () => { + it('stores and retrieves a debug analysis', async () => { + const runId = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + const analysisId = await storeDebugAnalysis({ + analyzedRunId: runId, + summary: 'Agent failed due to rate limit', + issues: 'Rate limit exceeded after 5 retries', + rootCause: 'Too many requests', + severity: 'high', + recommendations: 'Reduce request rate', + timeline: 'T+0: started, T+10: rate limit hit', + }); + + expect(analysisId).toBeTruthy(); + + const analysis = await getDebugAnalysisByRunId(runId); + expect(analysis).toBeDefined(); + expect(analysis?.summary).toBe('Agent failed due to rate limit'); + expect(analysis?.severity).toBe('high'); + }); + + it('returns null when no analysis exists', async () => { + const runId = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + const analysis = await getDebugAnalysisByRunId(runId); + expect(analysis).toBeNull(); + }); + + it('deletes a debug analysis', async () => { + const runId = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + + await storeDebugAnalysis({ + analyzedRunId: runId, + summary: 'Test summary', + issues: 'Test issues', + }); + + await deleteDebugAnalysisByRunId(runId); + + const analysis = await getDebugAnalysisByRunId(runId); + expect(analysis).toBeNull(); + }); + }); + + // ========================================================================= + // Dashboard queries + // ========================================================================= + + describe('listRuns', () => { + it('returns paginated runs with total count', async () => { + await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + await createRun({ projectId: 'test-project', agentType: 'review', backend: 'claude-code' }); + await createRun({ projectId: 'test-project', agentType: 'planning', backend: 'claude-code' }); + + const result = await listRuns({ orgId: 'test-org', limit: 10, offset: 0 }); + expect(result.data).toHaveLength(3); + expect(result.total).toBe(3); + }); + + it('filters by projectId', async () => { + await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); + await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + await createRun({ + projectId: 'project-2', + agentType: 'implementation', + backend: 'claude-code', + }); + + const result = await listRuns({ + orgId: 'test-org', + projectId: 'test-project', + limit: 10, + offset: 0, + }); + expect(result.data).toHaveLength(1); + expect(result.data[0].projectId).toBe('test-project'); + }); + + it('filters by status', async () => { + const id1 = await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + const id2 = await createRun({ + projectId: 'test-project', + agentType: 'review', + backend: 'claude-code', + }); + await completeRun(id1, { status: 'completed', success: true }); + await completeRun(id2, { status: 'failed', success: false }); + + const completed = await listRuns({ + orgId: 'test-org', + status: ['completed'], + limit: 10, + offset: 0, + }); + expect(completed.data).toHaveLength(1); + expect(completed.data[0].status).toBe('completed'); + }); + + it('filters by agentType', async () => { + await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + await createRun({ projectId: 'test-project', agentType: 'review', backend: 'claude-code' }); + + const result = await listRuns({ + orgId: 'test-org', + agentType: 'review', + limit: 10, + offset: 0, + }); + expect(result.data).toHaveLength(1); + expect(result.data[0].agentType).toBe('review'); + }); + + it('respects limit and offset for pagination', async () => { + for (let i = 0; i < 5; i++) { + await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + } + + const page1 = await listRuns({ orgId: 'test-org', limit: 2, offset: 0 }); + expect(page1.data).toHaveLength(2); + expect(page1.total).toBe(5); + + const page2 = await listRuns({ orgId: 'test-org', limit: 2, offset: 2 }); + expect(page2.data).toHaveLength(2); + expect(page2.total).toBe(5); + }); + + it('includes projectName in results', async () => { + await createRun({ + projectId: 'test-project', + agentType: 'implementation', + backend: 'claude-code', + }); + const result = await listRuns({ orgId: 'test-org', limit: 10, offset: 0 }); + expect(result.data[0].projectName).toBe('Test Project'); + }); + }); + + describe('listProjectsForOrg', () => { + it('returns all projects for an org', async () => { + await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); + const projects = await listProjectsForOrg('test-org'); + expect(projects).toHaveLength(2); + expect(projects.map((p) => p.id).sort()).toEqual(['project-2', 'test-project']); + }); + + it('returns empty array for org with no projects', async () => { + await seedOrg('empty-org', 'Empty Org'); + const projects = await listProjectsForOrg('empty-org'); + expect(projects).toEqual([]); + }); + }); +}); diff --git a/tests/integration/db/settingsRepository.test.ts b/tests/integration/db/settingsRepository.test.ts new file mode 100644 index 00000000..18e5fa0f --- /dev/null +++ b/tests/integration/db/settingsRepository.test.ts @@ -0,0 +1,444 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + createAgentConfig, + createProject, + deleteAgentConfig, + deleteProject, + deleteProjectIntegration, + getCascadeDefaults, + getOrganization, + getProjectFull, + listAgentConfigs, + listAllOrganizations, + listIntegrationCredentials, + listProjectIntegrations, + listProjectsFull, + removeIntegrationCredential, + setIntegrationCredential, + updateAgentConfig, + updateOrganization, + updateProject, + updateProjectIntegrationTriggers, + upsertCascadeDefaults, + upsertProjectIntegration, +} from '../../../src/db/repositories/settingsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedCredential, seedIntegration, seedOrg, seedProject } from '../helpers/seed.js'; + +describe('settingsRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // Organizations + // ========================================================================= + + describe('getOrganization', () => { + it('returns the organization', async () => { + const org = await getOrganization('test-org'); + expect(org).toBeDefined(); + expect(org?.id).toBe('test-org'); + expect(org?.name).toBe('Test Org'); + }); + + it('returns null for non-existent org', async () => { + const org = await getOrganization('nonexistent-org'); + expect(org).toBeNull(); + }); + }); + + describe('updateOrganization', () => { + it('updates the org name', async () => { + await updateOrganization('test-org', { name: 'Updated Org Name' }); + const org = await getOrganization('test-org'); + expect(org?.name).toBe('Updated Org Name'); + }); + }); + + describe('listAllOrganizations', () => { + it('returns all organizations', async () => { + await seedOrg('org-2', 'Org 2'); + const orgs = await listAllOrganizations(); + expect(orgs.length).toBeGreaterThanOrEqual(2); + expect(orgs.map((o) => o.id)).toContain('test-org'); + expect(orgs.map((o) => o.id)).toContain('org-2'); + }); + }); + + // ========================================================================= + // Cascade Defaults + // ========================================================================= + + describe('getCascadeDefaults', () => { + it('returns null when no defaults exist', async () => { + const defaults = await getCascadeDefaults('test-org'); + expect(defaults).toBeNull(); + }); + }); + + describe('upsertCascadeDefaults', () => { + it('inserts new defaults', async () => { + await upsertCascadeDefaults('test-org', { + model: 'claude-opus-4-5', + maxIterations: 30, + agentBackend: 'claude-code', + }); + const defaults = await getCascadeDefaults('test-org'); + expect(defaults?.model).toBe('claude-opus-4-5'); + expect(defaults?.maxIterations).toBe(30); + expect(defaults?.agentBackend).toBe('claude-code'); + }); + + it('updates existing defaults', async () => { + await upsertCascadeDefaults('test-org', { model: 'old-model', maxIterations: 20 }); + await upsertCascadeDefaults('test-org', { model: 'new-model', maxIterations: 40 }); + const defaults = await getCascadeDefaults('test-org'); + expect(defaults?.model).toBe('new-model'); + expect(defaults?.maxIterations).toBe(40); + }); + + it('allows null fields to clear values', async () => { + await upsertCascadeDefaults('test-org', { model: 'some-model' }); + await upsertCascadeDefaults('test-org', { model: null }); + const defaults = await getCascadeDefaults('test-org'); + expect(defaults?.model).toBeNull(); + }); + }); + + // ========================================================================= + // Projects + // ========================================================================= + + describe('createProject', () => { + it('creates a new project', async () => { + const project = await createProject('test-org', { + id: 'new-project', + name: 'New Project', + repo: 'owner/new-repo', + }); + expect(project.id).toBe('new-project'); + expect(project.orgId).toBe('test-org'); + expect(project.name).toBe('New Project'); + expect(project.baseBranch).toBe('main'); + }); + + it('creates a project with optional fields', async () => { + const project = await createProject('test-org', { + id: 'proj-opts', + name: 'Opts Project', + repo: 'owner/opts-repo', + baseBranch: 'develop', + branchPrefix: 'fix/', + model: 'claude-sonnet', + cardBudgetUsd: '10.00', + agentBackend: 'claude-code', + }); + expect(project.baseBranch).toBe('develop'); + expect(project.branchPrefix).toBe('fix/'); + expect(project.model).toBe('claude-sonnet'); + }); + }); + + describe('updateProject', () => { + it('updates project fields', async () => { + await updateProject('test-project', 'test-org', { + name: 'Updated Project', + model: 'claude-haiku', + }); + const project = await getProjectFull('test-project', 'test-org'); + expect(project?.name).toBe('Updated Project'); + expect(project?.model).toBe('claude-haiku'); + }); + }); + + describe('deleteProject', () => { + it('deletes a project', async () => { + await deleteProject('test-project', 'test-org'); + const project = await getProjectFull('test-project', 'test-org'); + expect(project).toBeNull(); + }); + }); + + describe('listProjectsFull', () => { + it('returns all projects for an org', async () => { + await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); + const projects = await listProjectsFull('test-org'); + expect(projects).toHaveLength(2); + }); + }); + + describe('getProjectFull', () => { + it('returns the full project', async () => { + const project = await getProjectFull('test-project', 'test-org'); + expect(project).toBeDefined(); + expect(project?.id).toBe('test-project'); + expect(project?.orgId).toBe('test-org'); + expect(project?.repo).toBe('owner/repo'); + }); + + it('returns null for wrong org', async () => { + const project = await getProjectFull('test-project', 'wrong-org'); + expect(project).toBeNull(); + }); + }); + + // ========================================================================= + // Project Integrations + // ========================================================================= + + describe('upsertProjectIntegration', () => { + it('inserts a new integration', async () => { + const integration = await upsertProjectIntegration('test-project', 'pm', 'trello', { + boardId: 'board-123', + }); + expect(integration.projectId).toBe('test-project'); + expect(integration.category).toBe('pm'); + expect(integration.provider).toBe('trello'); + }); + + it('updates an existing integration on conflict', async () => { + await upsertProjectIntegration('test-project', 'pm', 'trello', { boardId: 'board-old' }); + const updated = await upsertProjectIntegration('test-project', 'pm', 'trello', { + boardId: 'board-new', + }); + expect((updated.config as Record).boardId).toBe('board-new'); + }); + + it('preserves existing triggers when not provided', async () => { + await upsertProjectIntegration( + 'test-project', + 'pm', + 'trello', + { boardId: 'board-1' }, + { cardMovedToTodo: true }, + ); + // Upsert without triggers — should preserve existing + const updated = await upsertProjectIntegration('test-project', 'pm', 'trello', { + boardId: 'board-2', + }); + expect((updated.triggers as Record).cardMovedToTodo).toBe(true); + }); + }); + + describe('updateProjectIntegrationTriggers', () => { + it('deep-merges triggers', async () => { + await upsertProjectIntegration( + 'test-project', + 'pm', + 'trello', + {}, + { cardMovedToTodo: true, cardMovedToPlanning: true }, + ); + + await updateProjectIntegrationTriggers('test-project', 'pm', { + cardMovedToTodo: false, + reviewTrigger: { ownPrsOnly: true }, + }); + + const integrations = await listProjectIntegrations('test-project'); + const pmIntegration = integrations.find((i) => i.category === 'pm'); + const triggers = pmIntegration?.triggers as Record; + expect(triggers.cardMovedToTodo).toBe(false); + expect(triggers.cardMovedToPlanning).toBe(true); // preserved + expect((triggers.reviewTrigger as Record).ownPrsOnly).toBe(true); + }); + + it('throws when no integration found', async () => { + await expect( + updateProjectIntegrationTriggers('test-project', 'scm', { ownPrsOnly: true }), + ).rejects.toThrow(); + }); + }); + + describe('deleteProjectIntegration', () => { + it('deletes a project integration', async () => { + await upsertProjectIntegration('test-project', 'pm', 'trello', {}); + await deleteProjectIntegration('test-project', 'pm'); + const integrations = await listProjectIntegrations('test-project'); + expect(integrations.find((i) => i.category === 'pm')).toBeUndefined(); + }); + }); + + // ========================================================================= + // Integration Credentials + // ========================================================================= + + describe('listIntegrationCredentials / setIntegrationCredential / removeIntegrationCredential', () => { + it('sets and lists integration credentials', async () => { + const integration = await seedIntegration({ category: 'scm', provider: 'github' }); + const cred = await seedCredential({ + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + value: 'ghp_123', + }); + + await setIntegrationCredential(integration.id, 'implementer_token', cred.id); + + const creds = await listIntegrationCredentials(integration.id); + expect(creds).toHaveLength(1); + expect(creds[0].role).toBe('implementer_token'); + expect(creds[0].credentialId).toBe(cred.id); + expect(creds[0].credentialName).toBe('Test Key'); + }); + + it('upserts an integration credential (replace existing role)', async () => { + const integration = await seedIntegration({ category: 'scm', provider: 'github' }); + const cred1 = await seedCredential({ envVarKey: 'GH_1', value: 'v1', name: 'Cred 1' }); + const cred2 = await seedCredential({ envVarKey: 'GH_2', value: 'v2', name: 'Cred 2' }); + + await setIntegrationCredential(integration.id, 'implementer_token', cred1.id); + await setIntegrationCredential(integration.id, 'implementer_token', cred2.id); + + const creds = await listIntegrationCredentials(integration.id); + expect(creds).toHaveLength(1); + expect(creds[0].credentialId).toBe(cred2.id); + }); + + it('removes an integration credential', async () => { + const integration = await seedIntegration({ category: 'scm', provider: 'github' }); + const cred = await seedCredential({ envVarKey: 'GH_KEY', value: 'ghp_abc' }); + + await setIntegrationCredential(integration.id, 'implementer_token', cred.id); + await removeIntegrationCredential(integration.id, 'implementer_token'); + + const creds = await listIntegrationCredentials(integration.id); + expect(creds).toHaveLength(0); + }); + }); + + // ========================================================================= + // Agent Configs + // ========================================================================= + + describe('listAgentConfigs', () => { + it('lists all agent configs when no filter given', async () => { + await createAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'global-model', + }); + await createAgentConfig({ + orgId: 'test-org', + projectId: null, + agentType: 'review', + model: 'org-model', + }); + await createAgentConfig({ + orgId: null, + projectId: 'test-project', + agentType: 'planning', + model: 'proj-model', + }); + + const configs = await listAgentConfigs(); + expect(configs.length).toBeGreaterThanOrEqual(3); + }); + + it('filters by projectId', async () => { + await createAgentConfig({ + orgId: null, + projectId: 'test-project', + agentType: 'implementation', + model: 'proj-model', + }); + await createAgentConfig({ + orgId: null, + projectId: null, + agentType: 'review', + model: 'global-model', + }); + + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs.every((c) => c.projectId === 'test-project')).toBe(true); + }); + + it('filters by orgId (returns global + org-level configs with null projectId)', async () => { + await createAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'global-model', + }); + await createAgentConfig({ + orgId: 'test-org', + projectId: null, + agentType: 'review', + model: 'org-model', + }); + await createAgentConfig({ + orgId: null, + projectId: 'test-project', + agentType: 'planning', + model: 'proj-model', + }); + + const configs = await listAgentConfigs({ orgId: 'test-org' }); + // Should return configs where projectId is null (global + org-level) + expect(configs.every((c) => c.projectId === null)).toBe(true); + }); + }); + + describe('createAgentConfig', () => { + it('creates a global agent config', async () => { + const { id } = await createAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'claude-opus-4-5', + maxIterations: 30, + }); + expect(id).toBeGreaterThan(0); + }); + + it('creates a project-scoped agent config', async () => { + const { id } = await createAgentConfig({ + orgId: null, + projectId: 'test-project', + agentType: 'review', + model: 'claude-sonnet', + }); + expect(id).toBeGreaterThan(0); + + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs.find((c) => c.id === id)?.model).toBe('claude-sonnet'); + }); + }); + + describe('updateAgentConfig', () => { + it('updates an agent config', async () => { + const { id } = await createAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'old-model', + maxIterations: 10, + }); + + await updateAgentConfig(id, { model: 'new-model', maxIterations: 20 }); + + const configs = await listAgentConfigs(); + const config = configs.find((c) => c.id === id); + expect(config?.model).toBe('new-model'); + expect(config?.maxIterations).toBe(20); + }); + }); + + describe('deleteAgentConfig', () => { + it('deletes an agent config', async () => { + const { id } = await createAgentConfig({ + orgId: null, + projectId: null, + agentType: 'implementation', + model: 'to-delete', + }); + + await deleteAgentConfig(id); + + const configs = await listAgentConfigs(); + expect(configs.find((c) => c.id === id)).toBeUndefined(); + }); + }); +}); diff --git a/tests/integration/db/usersRepository.test.ts b/tests/integration/db/usersRepository.test.ts new file mode 100644 index 00000000..7d0f8cd5 --- /dev/null +++ b/tests/integration/db/usersRepository.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + createSession, + deleteExpiredSessions, + deleteSession, + getSessionByToken, + getUserByEmail, + getUserById, +} from '../../../src/db/repositories/usersRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedOrg, seedProject, seedSession, seedUser } from '../helpers/seed.js'; + +describe('usersRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // getUserByEmail + // ========================================================================= + + describe('getUserByEmail', () => { + it('returns the user for an existing email', async () => { + await seedUser({ email: 'alice@example.com', name: 'Alice' }); + + const user = await getUserByEmail('alice@example.com'); + expect(user).toBeDefined(); + expect(user?.email).toBe('alice@example.com'); + expect(user?.name).toBe('Alice'); + }); + + it('returns null for non-existent email', async () => { + const user = await getUserByEmail('nobody@example.com'); + expect(user).toBeNull(); + }); + + it('returns the password hash (needed for auth)', async () => { + await seedUser({ email: 'bob@example.com', passwordHash: '$2b$10$abcdefghij' }); + const user = await getUserByEmail('bob@example.com'); + expect(user?.passwordHash).toBe('$2b$10$abcdefghij'); + }); + }); + + // ========================================================================= + // getUserById + // ========================================================================= + + describe('getUserById', () => { + it('returns the user without password hash', async () => { + const seeded = await seedUser({ email: 'carol@example.com', name: 'Carol', role: 'admin' }); + + const user = await getUserById(seeded.id); + expect(user).toBeDefined(); + expect(user?.id).toBe(seeded.id); + expect(user?.email).toBe('carol@example.com'); + expect(user?.name).toBe('Carol'); + expect(user?.role).toBe('admin'); + expect(user?.orgId).toBe('test-org'); + // getUserById returns DashboardUser which doesn't have passwordHash + expect('passwordHash' in (user ?? {})).toBe(false); + }); + + it('returns null for non-existent ID', async () => { + const user = await getUserById('00000000-0000-0000-0000-000000000000'); + expect(user).toBeNull(); + }); + }); + + // ========================================================================= + // createSession / getSessionByToken + // ========================================================================= + + describe('createSession', () => { + it('creates a session and returns the ID', async () => { + const user = await seedUser({ email: 'dave@example.com' }); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); + + const sessionId = await createSession(user.id, 'my-session-token', expiresAt); + expect(sessionId).toBeTruthy(); + }); + }); + + describe('getSessionByToken', () => { + it('returns session for valid non-expired token', async () => { + const user = await seedUser({ email: 'eve@example.com' }); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); + + await createSession(user.id, 'valid-token', expiresAt); + + const session = await getSessionByToken('valid-token'); + expect(session).toBeDefined(); + expect(session?.userId).toBe(user.id); + }); + + it('returns null for expired token', async () => { + const user = await seedUser({ email: 'frank@example.com' }); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() - 1); // expired yesterday + + await createSession(user.id, 'expired-token', expiresAt); + + const session = await getSessionByToken('expired-token'); + expect(session).toBeNull(); + }); + + it('returns null for non-existent token', async () => { + const session = await getSessionByToken('nonexistent-token'); + expect(session).toBeNull(); + }); + }); + + // ========================================================================= + // deleteSession + // ========================================================================= + + describe('deleteSession', () => { + it('removes the session', async () => { + const user = await seedUser({ email: 'grace@example.com' }); + await seedSession({ userId: user.id, token: 'to-delete-token' }); + + await deleteSession('to-delete-token'); + + const session = await getSessionByToken('to-delete-token'); + expect(session).toBeNull(); + }); + + it('does nothing when deleting non-existent token', async () => { + await expect(deleteSession('nonexistent-token')).resolves.toBeUndefined(); + }); + }); + + // ========================================================================= + // deleteExpiredSessions + // ========================================================================= + + describe('deleteExpiredSessions', () => { + it('removes expired sessions only', async () => { + const user = await seedUser({ email: 'henry@example.com' }); + + const validExpiry = new Date(); + validExpiry.setDate(validExpiry.getDate() + 30); + const expiredExpiry = new Date(); + expiredExpiry.setDate(expiredExpiry.getDate() - 1); + + await seedSession({ userId: user.id, token: 'valid-session', expiresAt: validExpiry }); + await seedSession({ userId: user.id, token: 'expired-session-1', expiresAt: expiredExpiry }); + await seedSession({ userId: user.id, token: 'expired-session-2', expiresAt: expiredExpiry }); + + await deleteExpiredSessions(); + + // Valid session still exists + const validSession = await getSessionByToken('valid-session'); + expect(validSession).toBeDefined(); + + // Expired sessions are gone + const expired1 = await getSessionByToken('expired-session-1'); + expect(expired1).toBeNull(); + const expired2 = await getSessionByToken('expired-session-2'); + expect(expired2).toBeNull(); + }); + }); +}); diff --git a/tests/integration/db/webhookLogsRepository.test.ts b/tests/integration/db/webhookLogsRepository.test.ts new file mode 100644 index 00000000..e19da085 --- /dev/null +++ b/tests/integration/db/webhookLogsRepository.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + getWebhookLogById, + getWebhookLogStats, + insertWebhookLog, + listWebhookLogs, + pruneWebhookLogs, +} from '../../../src/db/repositories/webhookLogsRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedOrg, seedProject, seedWebhookLog } from '../helpers/seed.js'; + +describe('webhookLogsRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + await seedProject(); + }); + + // ========================================================================= + // insertWebhookLog / getWebhookLogById + // ========================================================================= + + describe('insertWebhookLog', () => { + it('inserts a webhook log and returns the ID', async () => { + const id = await insertWebhookLog({ + source: 'trello', + method: 'POST', + path: '/webhooks/trello', + }); + expect(id).toBeTruthy(); + expect(typeof id).toBe('string'); + }); + + it('stores all fields including JSONB headers and body', async () => { + const id = await insertWebhookLog({ + source: 'github', + method: 'POST', + path: '/webhooks/github', + headers: { 'x-github-event': 'push', 'content-type': 'application/json' }, + body: { ref: 'refs/heads/main', repository: { full_name: 'owner/repo' } }, + bodyRaw: '{"ref":"refs/heads/main"}', + statusCode: 200, + projectId: 'test-project', + eventType: 'push', + processed: true, + }); + + const log = await getWebhookLogById(id); + expect(log).toBeDefined(); + expect(log?.source).toBe('github'); + expect(log?.method).toBe('POST'); + expect(log?.path).toBe('/webhooks/github'); + expect((log?.headers as Record)['x-github-event']).toBe('push'); + expect((log?.body as Record).ref).toBe('refs/heads/main'); + expect(log?.bodyRaw).toBe('{"ref":"refs/heads/main"}'); + expect(log?.statusCode).toBe(200); + expect(log?.projectId).toBe('test-project'); + expect(log?.eventType).toBe('push'); + expect(log?.processed).toBe(true); + }); + + it('defaults processed to false', async () => { + const id = await insertWebhookLog({ + source: 'trello', + method: 'POST', + path: '/webhooks/trello', + }); + const log = await getWebhookLogById(id); + expect(log?.processed).toBe(false); + }); + }); + + describe('getWebhookLogById', () => { + it('returns null for non-existent ID', async () => { + const log = await getWebhookLogById('00000000-0000-0000-0000-000000000000'); + expect(log).toBeNull(); + }); + }); + + // ========================================================================= + // listWebhookLogs + // ========================================================================= + + describe('listWebhookLogs', () => { + it('returns all logs with total count', async () => { + await seedWebhookLog({ source: 'trello' }); + await seedWebhookLog({ source: 'github' }); + await seedWebhookLog({ source: 'trello' }); + + const result = await listWebhookLogs({ limit: 10, offset: 0 }); + expect(result.data).toHaveLength(3); + expect(result.total).toBe(3); + }); + + it('filters by source', async () => { + await seedWebhookLog({ source: 'trello' }); + await seedWebhookLog({ source: 'github' }); + await seedWebhookLog({ source: 'trello' }); + + const result = await listWebhookLogs({ source: 'trello', limit: 10, offset: 0 }); + expect(result.data).toHaveLength(2); + expect(result.data.every((l) => l.source === 'trello')).toBe(true); + }); + + it('filters by eventType', async () => { + await seedWebhookLog({ source: 'trello', eventType: 'updateCard' }); + await seedWebhookLog({ source: 'trello', eventType: 'createCard' }); + await seedWebhookLog({ source: 'github', eventType: 'push' }); + + const result = await listWebhookLogs({ eventType: 'updateCard', limit: 10, offset: 0 }); + expect(result.data).toHaveLength(1); + expect(result.data[0].eventType).toBe('updateCard'); + }); + + it('respects limit and offset for pagination', async () => { + for (let i = 0; i < 5; i++) { + await seedWebhookLog({ source: 'trello' }); + } + + const page1 = await listWebhookLogs({ limit: 2, offset: 0 }); + expect(page1.data).toHaveLength(2); + expect(page1.total).toBe(5); + + const page2 = await listWebhookLogs({ limit: 2, offset: 2 }); + expect(page2.data).toHaveLength(2); + expect(page2.total).toBe(5); + }); + + it('returns logs ordered by receivedAt descending', async () => { + await seedWebhookLog({ source: 'trello', eventType: 'first' }); + await seedWebhookLog({ source: 'trello', eventType: 'second' }); + await seedWebhookLog({ source: 'trello', eventType: 'third' }); + + const result = await listWebhookLogs({ limit: 10, offset: 0 }); + // Most recent first + expect(result.data[0].eventType).toBe('third'); + expect(result.data[2].eventType).toBe('first'); + }); + + it('filters by receivedAfter date', async () => { + const before = new Date(); + before.setMinutes(before.getMinutes() - 10); + const after = new Date(); + after.setMinutes(after.getMinutes() + 10); + + await seedWebhookLog({ source: 'trello' }); + + const result = await listWebhookLogs({ receivedAfter: after, limit: 10, offset: 0 }); + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + }); + }); + + // ========================================================================= + // pruneWebhookLogs + // ========================================================================= + + describe('pruneWebhookLogs', () => { + it('retains only the most recent N logs', async () => { + for (let i = 0; i < 5; i++) { + await seedWebhookLog({ source: 'trello', eventType: `event-${i}` }); + } + + await pruneWebhookLogs(3); + + const result = await listWebhookLogs({ limit: 100, offset: 0 }); + expect(result.data).toHaveLength(3); + expect(result.total).toBe(3); + }); + + it('does nothing when count is already below retention limit', async () => { + await seedWebhookLog({ source: 'trello' }); + await seedWebhookLog({ source: 'github' }); + + await pruneWebhookLogs(10); + + const result = await listWebhookLogs({ limit: 100, offset: 0 }); + expect(result.total).toBe(2); + }); + }); + + // ========================================================================= + // getWebhookLogStats + // ========================================================================= + + describe('getWebhookLogStats', () => { + it('returns count grouped by source', async () => { + await seedWebhookLog({ source: 'trello' }); + await seedWebhookLog({ source: 'trello' }); + await seedWebhookLog({ source: 'github' }); + await seedWebhookLog({ source: 'jira' }); + + const stats = await getWebhookLogStats(); + expect(stats.length).toBeGreaterThanOrEqual(3); + + const trelloStat = stats.find((s) => s.source === 'trello'); + const githubStat = stats.find((s) => s.source === 'github'); + const jiraStat = stats.find((s) => s.source === 'jira'); + + expect(trelloStat?.count).toBe(2); + expect(githubStat?.count).toBe(1); + expect(jiraStat?.count).toBe(1); + }); + + it('returns empty array when no logs exist', async () => { + const stats = await getWebhookLogStats(); + expect(stats).toEqual([]); + }); + }); +}); diff --git a/tests/integration/helpers/seed.ts b/tests/integration/helpers/seed.ts index 5c1c6a7f..9010832f 100644 --- a/tests/integration/helpers/seed.ts +++ b/tests/integration/helpers/seed.ts @@ -1,10 +1,19 @@ import { getDb } from '../../../src/db/client.js'; import { + agentConfigs, + agentRunLogs, + agentRuns, + cascadeDefaults, credentials, integrationCredentials, organizations, + prWorkItems, projectIntegrations, projects, + promptPartials, + sessions, + users, + webhookLogs, } from '../../../src/db/schema/index.js'; /** @@ -115,3 +124,225 @@ export async function seedIntegrationCredential(overrides: { .returning(); return row; } + +/** + * Seeds cascade defaults for an org. + */ +export async function seedDefaults( + overrides: { + orgId?: string; + model?: string | null; + maxIterations?: number | null; + agentBackend?: string | null; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(cascadeDefaults) + .values({ + orgId: overrides.orgId ?? 'test-org', + model: overrides.model ?? null, + maxIterations: overrides.maxIterations ?? null, + agentBackend: overrides.agentBackend ?? null, + }) + .returning(); + return row; +} + +/** + * Seeds an agent config row. + */ +export async function seedAgentConfig( + overrides: { + orgId?: string | null; + projectId?: string | null; + agentType?: string; + model?: string | null; + maxIterations?: number | null; + agentBackend?: string | null; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(agentConfigs) + .values({ + orgId: overrides.orgId ?? null, + projectId: overrides.projectId ?? null, + agentType: overrides.agentType ?? 'implementation', + model: overrides.model ?? null, + maxIterations: overrides.maxIterations ?? null, + agentBackend: overrides.agentBackend ?? null, + }) + .returning(); + return row; +} + +/** + * Seeds an agent run row. + */ +export async function seedRun( + overrides: { + projectId?: string; + cardId?: string; + agentType?: string; + backend?: string; + status?: string; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(agentRuns) + .values({ + projectId: overrides.projectId ?? 'test-project', + cardId: overrides.cardId ?? 'test-card', + agentType: overrides.agentType ?? 'implementation', + backend: overrides.backend ?? 'claude-code', + status: overrides.status ?? 'running', + }) + .returning(); + return row; +} + +/** + * Seeds a user row linked to an org. + */ +export async function seedUser( + overrides: { + orgId?: string; + email?: string; + name?: string; + passwordHash?: string; + role?: string; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(users) + .values({ + orgId: overrides.orgId ?? 'test-org', + email: overrides.email ?? 'test@example.com', + name: overrides.name ?? 'Test User', + passwordHash: overrides.passwordHash ?? '$2b$10$hashedpassword', + role: overrides.role ?? 'member', + }) + .returning(); + return row; +} + +/** + * Seeds a webhook log row. + */ +export async function seedWebhookLog( + overrides: { + source?: string; + method?: string; + path?: string; + eventType?: string; + projectId?: string; + headers?: Record; + body?: Record; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(webhookLogs) + .values({ + source: overrides.source ?? 'trello', + method: overrides.method ?? 'POST', + path: overrides.path ?? '/webhooks/trello', + eventType: overrides.eventType ?? 'updateCard', + projectId: overrides.projectId, + headers: overrides.headers, + body: overrides.body, + }) + .returning(); + return row; +} + +/** + * Seeds a prompt partial row. + */ +export async function seedPromptPartial( + overrides: { + orgId?: string | null; + name?: string; + content?: string; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(promptPartials) + .values({ + orgId: overrides.orgId ?? null, + name: overrides.name ?? 'test-partial', + content: overrides.content ?? 'Test partial content', + }) + .returning(); + return row; +} + +/** + * Seeds a PR work item link. + */ +export async function seedPrWorkItem( + overrides: { + projectId?: string; + repoFullName?: string; + prNumber?: number; + workItemId?: string; + } = {}, +) { + const db = getDb(); + const [row] = await db + .insert(prWorkItems) + .values({ + projectId: overrides.projectId ?? 'test-project', + repoFullName: overrides.repoFullName ?? 'owner/repo', + prNumber: overrides.prNumber ?? 1, + workItemId: overrides.workItemId ?? 'card-abc123', + }) + .returning(); + return row; +} + +/** + * Seeds a session for a user. + */ +export async function seedSession(overrides: { + userId: string; + token?: string; + expiresAt?: Date; +}) { + const db = getDb(); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + const [row] = await db + .insert(sessions) + .values({ + userId: overrides.userId, + token: overrides.token ?? 'test-session-token', + expiresAt: overrides.expiresAt ?? futureDate, + }) + .returning(); + return row; +} + +/** + * Seeds run logs for an agent run. + */ +export async function seedRunLogs(overrides: { + runId: string; + cascadeLog?: string; + llmistLog?: string; +}) { + const db = getDb(); + const [row] = await db + .insert(agentRunLogs) + .values({ + runId: overrides.runId, + cascadeLog: overrides.cascadeLog ?? 'Test cascade log', + llmistLog: overrides.llmistLog ?? null, + }) + .returning(); + return row; +} From 0e1cc60c5fcfd71c6d6438d83b7a938aab2b74ff Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Wed, 25 Feb 2026 12:42:39 +0000 Subject: [PATCH 2/2] fix(tests): remove unused imports and dead code in integration tests Remove unused `seedRun` import from runsRepository.test.ts, and remove unused `seedPrWorkItem` and `seedRunLogs` helpers from seed.ts along with their now-unnecessary schema imports. Co-Authored-By: Claude Opus 4.6 --- tests/integration/db/runsRepository.test.ts | 2 +- tests/integration/helpers/seed.ts | 46 --------------------- 2 files changed, 1 insertion(+), 47 deletions(-) diff --git a/tests/integration/db/runsRepository.test.ts b/tests/integration/db/runsRepository.test.ts index 9fe330e2..29cca650 100644 --- a/tests/integration/db/runsRepository.test.ts +++ b/tests/integration/db/runsRepository.test.ts @@ -19,7 +19,7 @@ import { storeRunLogs, } from '../../../src/db/repositories/runsRepository.js'; import { truncateAll } from '../helpers/db.js'; -import { seedOrg, seedProject, seedRun } from '../helpers/seed.js'; +import { seedOrg, seedProject } from '../helpers/seed.js'; describe('runsRepository (integration)', () => { beforeEach(async () => { diff --git a/tests/integration/helpers/seed.ts b/tests/integration/helpers/seed.ts index 9010832f..53085934 100644 --- a/tests/integration/helpers/seed.ts +++ b/tests/integration/helpers/seed.ts @@ -1,13 +1,11 @@ import { getDb } from '../../../src/db/client.js'; import { agentConfigs, - agentRunLogs, agentRuns, cascadeDefaults, credentials, integrationCredentials, organizations, - prWorkItems, projectIntegrations, projects, promptPartials, @@ -281,30 +279,6 @@ export async function seedPromptPartial( return row; } -/** - * Seeds a PR work item link. - */ -export async function seedPrWorkItem( - overrides: { - projectId?: string; - repoFullName?: string; - prNumber?: number; - workItemId?: string; - } = {}, -) { - const db = getDb(); - const [row] = await db - .insert(prWorkItems) - .values({ - projectId: overrides.projectId ?? 'test-project', - repoFullName: overrides.repoFullName ?? 'owner/repo', - prNumber: overrides.prNumber ?? 1, - workItemId: overrides.workItemId ?? 'card-abc123', - }) - .returning(); - return row; -} - /** * Seeds a session for a user. */ @@ -326,23 +300,3 @@ export async function seedSession(overrides: { .returning(); return row; } - -/** - * Seeds run logs for an agent run. - */ -export async function seedRunLogs(overrides: { - runId: string; - cascadeLog?: string; - llmistLog?: string; -}) { - const db = getDb(); - const [row] = await db - .insert(agentRunLogs) - .values({ - runId: overrides.runId, - cascadeLog: overrides.cascadeLog ?? 'Test cascade log', - llmistLog: overrides.llmistLog ?? null, - }) - .returning(); - return row; -}