From 8004f6d055279bb70e16232494771bb19cc06bcf Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 14 Apr 2026 20:47:05 +0000 Subject: [PATCH] test(linear): add unit tests for Linear trigger handlers --- .../triggers/linear-comment-mention.test.ts | 234 ++++++++++++++ .../unit/triggers/linear-label-added.test.ts | 298 ++++++++++++++++++ .../triggers/linear-status-changed.test.ts | 256 +++++++++++++++ 3 files changed, 788 insertions(+) create mode 100644 tests/unit/triggers/linear-comment-mention.test.ts create mode 100644 tests/unit/triggers/linear-label-added.test.ts create mode 100644 tests/unit/triggers/linear-status-changed.test.ts diff --git a/tests/unit/triggers/linear-comment-mention.test.ts b/tests/unit/triggers/linear-comment-mention.test.ts new file mode 100644 index 00000000..c8a80c71 --- /dev/null +++ b/tests/unit/triggers/linear-comment-mention.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockLogger, mockTriggerCheckModule } from '../../helpers/sharedMocks.js'; + +vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); +vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); + +// Mock resolveLinearBotUserId to avoid real API calls +const mockResolveLinearBotUserId = vi.fn(); +vi.mock('../../../src/router/bot-identity-resolvers.js', () => ({ + resolveLinearBotUserId: (...args: unknown[]) => mockResolveLinearBotUserId(...args), +})); + +import { LinearCommentMentionTrigger } from '../../../src/triggers/linear/comment-mention.js'; +import { checkTriggerEnabled } from '../../../src/triggers/shared/trigger-check.js'; +import type { TriggerContext } from '../../../src/types/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const BOT_USER_ID = 'bot-user-uuid-001'; +const OTHER_USER_ID = 'user-other-uuid-456'; +const ISSUE_IDENTIFIER = 'TEAM-99'; +const ISSUE_ID = 'issue-uuid-99'; + +const mockProject = { + id: 'proj-linear', + orgId: 'org-1', + name: 'Linear Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'linear' as const }, +} as TriggerContext['project']; + +function buildCtx( + overrides: { + source?: TriggerContext['source']; + action?: string; + type?: string; + commentBody?: string; + commentAuthorId?: string; + issueIdentifier?: string; + issueId?: string; + issueUrl?: string; + noIssue?: boolean; + } = {}, +): TriggerContext { + const issue = overrides.noIssue + ? undefined + : { + id: overrides.issueId ?? ISSUE_ID, + identifier: overrides.issueIdentifier ?? ISSUE_IDENTIFIER, + title: 'Test issue', + teamId: 'team-abc', + url: overrides.issueUrl ?? 'https://linear.app/org/issue/TEAM-99', + stateId: 'state-todo', + }; + + return { + project: mockProject, + source: overrides.source ?? 'linear', + payload: { + action: overrides.action ?? 'create', + type: overrides.type ?? 'Comment', + organizationId: 'org-123', + webhookTimestamp: Date.now(), + data: { + id: 'comment-uuid', + // Include botUserId in the body to simulate an @mention + body: overrides.commentBody ?? `@[Bot User](${BOT_USER_ID}) please help with this issue`, + issueId: ISSUE_ID, + userId: overrides.commentAuthorId ?? OTHER_USER_ID, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + issue, + }, + url: 'https://linear.app', + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('LinearCommentMentionTrigger', () => { + let trigger: LinearCommentMentionTrigger; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkTriggerEnabled).mockResolvedValue(true); + mockResolveLinearBotUserId.mockResolvedValue(BOT_USER_ID); + trigger = new LinearCommentMentionTrigger(); + }); + + // ========================================================================= + // matches + // ========================================================================= + describe('matches', () => { + it('matches create/Comment events from linear source', () => { + expect(trigger.matches(buildCtx())).toBe(true); + }); + + it('does not match non-linear source', () => { + expect(trigger.matches(buildCtx({ source: 'jira' }))).toBe(false); + }); + + it('does not match non-create actions', () => { + expect(trigger.matches(buildCtx({ action: 'update' }))).toBe(false); + }); + + it('does not match non-Comment types', () => { + expect(trigger.matches(buildCtx({ type: 'Issue' }))).toBe(false); + }); + + it('does not match IssueLabel type', () => { + expect(trigger.matches(buildCtx({ type: 'IssueLabel' }))).toBe(false); + }); + }); + + // ========================================================================= + // handle + // ========================================================================= + describe('handle', () => { + it('returns respond-to-planning-comment result when @mention found', async () => { + const result = await trigger.handle(buildCtx()); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('respond-to-planning-comment'); + expect(result?.workItemId).toBe(ISSUE_IDENTIFIER); + expect(result?.workItemUrl).toBe('https://linear.app/org/issue/TEAM-99'); + expect(result?.agentInput.workItemId).toBe(ISSUE_IDENTIFIER); + expect(result?.agentInput.triggerEvent).toBe('pm:comment-mention'); + }); + + it('includes triggerCommentText in agentInput', async () => { + const body = `@[Bot](${BOT_USER_ID}) please implement feature X`; + const result = await trigger.handle(buildCtx({ commentBody: body })); + + expect(result?.agentInput.triggerCommentText).toBe(body); + }); + + it('includes commentAuthorId in agentInput', async () => { + const result = await trigger.handle(buildCtx({ commentAuthorId: OTHER_USER_ID })); + + expect(result?.agentInput.triggerCommentAuthor).toBe(OTHER_USER_ID); + }); + + it('returns null when trigger is disabled', async () => { + vi.mocked(checkTriggerEnabled).mockResolvedValue(false); + + const result = await trigger.handle(buildCtx()); + + expect(result).toBeNull(); + expect(checkTriggerEnabled).toHaveBeenCalledWith( + 'proj-linear', + 'respond-to-planning-comment', + 'pm:comment-mention', + 'linear-comment-mention', + ); + }); + + it('returns null when issueIdentifier is missing', async () => { + const result = await trigger.handle(buildCtx({ noIssue: true })); + expect(result).toBeNull(); + }); + + it('returns null when commentBody is missing', async () => { + const ctx = buildCtx({ commentBody: '' }); + const data = ctx.payload as Record; + (data.data as Record).body = ''; + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when bot userId cannot be resolved', async () => { + mockResolveLinearBotUserId.mockResolvedValue(null); + + const result = await trigger.handle(buildCtx()); + + expect(result).toBeNull(); + }); + + it('returns null when comment is self-authored by the bot', async () => { + // Comment author is the bot itself + const result = await trigger.handle(buildCtx({ commentAuthorId: BOT_USER_ID })); + expect(result).toBeNull(); + }); + + it('returns null when comment body does not @mention the bot', async () => { + // No bot userId in the body + const result = await trigger.handle( + buildCtx({ commentBody: 'Just a regular comment, no mention' }), + ); + expect(result).toBeNull(); + }); + + it('includes linearIssueId in agentInput', async () => { + const result = await trigger.handle(buildCtx({ issueId: 'issue-uuid-99' })); + + expect(result?.agentInput.linearIssueId).toBe('issue-uuid-99'); + }); + + it('uses issue.id as fallback when identifier is missing', async () => { + const ctx = buildCtx(); + const data = ctx.payload as Record; + (data.data as Record).issue = { + id: 'fallback-issue-id', + // no identifier + url: 'https://linear.app/org/issue/fallback', + }; + const result = await trigger.handle(ctx); + expect(result?.workItemId).toBe('fallback-issue-id'); + }); + + it('resolves botUserId using the project ID', async () => { + await trigger.handle(buildCtx()); + + expect(mockResolveLinearBotUserId).toHaveBeenCalledWith('proj-linear'); + }); + + it('workItemTitle is undefined (not available in comment webhook)', async () => { + const result = await trigger.handle(buildCtx()); + expect(result?.workItemTitle).toBeUndefined(); + }); + + it('uses issueId from data.issueId when issue is present in data', async () => { + const ctx = buildCtx({ issueId: 'issue-uuid-99' }); + const result = await trigger.handle(ctx); + expect(result?.agentInput.linearIssueId).toBe('issue-uuid-99'); + }); + }); +}); diff --git a/tests/unit/triggers/linear-label-added.test.ts b/tests/unit/triggers/linear-label-added.test.ts new file mode 100644 index 00000000..6427aefb --- /dev/null +++ b/tests/unit/triggers/linear-label-added.test.ts @@ -0,0 +1,298 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockLogger, mockTriggerCheckModule } from '../../helpers/sharedMocks.js'; + +vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); +vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); + +const mockGetLinearConfig = vi.fn(); +vi.mock('../../../src/pm/config.js', () => ({ + getLinearConfig: (...args: unknown[]) => mockGetLinearConfig(...args), +})); + +// Mock resolveProjectPMConfig to avoid pmRegistry bootstrap side effects +const mockResolveProjectPMConfig = vi.fn(); +vi.mock('../../../src/pm/lifecycle.js', () => ({ + resolveProjectPMConfig: (...args: unknown[]) => mockResolveProjectPMConfig(...args), +})); + +import { LinearReadyToProcessLabelTrigger } from '../../../src/triggers/linear/label-added.js'; +import { checkTriggerEnabled } from '../../../src/triggers/shared/trigger-check.js'; +import type { TriggerContext } from '../../../src/types/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const baseLinearConfig = { + teamId: 'team-abc', + statuses: { + splitting: 'state-splitting', + planning: 'state-planning', + todo: 'state-todo', + backlog: 'state-backlog', + done: 'state-done', + }, +}; + +const baseProjectPMConfig = { + labels: { + processing: 'cascade-processing', + processed: 'cascade-processed', + error: 'cascade-error', + readyToProcess: 'cascade-ready', + auto: 'cascade-auto', + }, + statuses: { + backlog: 'state-backlog', + inProgress: 'state-in-progress', + done: 'state-done', + }, +}; + +const mockProject = { + id: 'proj-linear', + orgId: 'org-1', + name: 'Linear Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'linear' as const }, +} as TriggerContext['project']; + +function buildCtx( + overrides: { + source?: TriggerContext['source']; + action?: string; + type?: string; + labelName?: string; + labelId?: string; + issueStateId?: string; + issueIdentifier?: string; + issueId?: string; + issueUrl?: string; + readyToProcessLabel?: string; + noLinearConfig?: boolean; + } = {}, +): TriggerContext { + return { + project: mockProject, + source: overrides.source ?? 'linear', + payload: { + action: overrides.action ?? 'create', + type: overrides.type ?? 'IssueLabel', + organizationId: 'org-123', + webhookTimestamp: Date.now(), + data: { + id: 'issuelabel-uuid', + issueId: 'issue-uuid', + labelId: overrides.labelId ?? 'label-cascade-ready', + label: { + id: overrides.labelId ?? 'label-cascade-ready', + name: overrides.labelName ?? 'cascade-ready', + }, + issue: { + id: overrides.issueId ?? 'issue-uuid', + identifier: overrides.issueIdentifier ?? 'TEAM-123', + title: 'Fix the bug', + teamId: 'team-abc', + url: overrides.issueUrl ?? 'https://linear.app/org/issue/TEAM-123', + stateId: overrides.issueStateId ?? 'state-todo', + }, + }, + url: 'https://linear.app', + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('LinearReadyToProcessLabelTrigger', () => { + let trigger: LinearReadyToProcessLabelTrigger; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkTriggerEnabled).mockResolvedValue(true); + mockGetLinearConfig.mockReturnValue(baseLinearConfig); + mockResolveProjectPMConfig.mockReturnValue(baseProjectPMConfig); + trigger = new LinearReadyToProcessLabelTrigger(); + }); + + // ========================================================================= + // matches + // ========================================================================= + describe('matches', () => { + it('matches create/IssueLabel events with the ready-to-process label', () => { + expect(trigger.matches(buildCtx())).toBe(true); + }); + + it('does not match non-linear source', () => { + expect(trigger.matches(buildCtx({ source: 'jira' }))).toBe(false); + }); + + it('does not match non-create actions', () => { + expect(trigger.matches(buildCtx({ action: 'update' }))).toBe(false); + }); + + it('does not match non-IssueLabel types', () => { + expect(trigger.matches(buildCtx({ type: 'Issue' }))).toBe(false); + }); + + it('does not match when label name does not match readyToProcess', () => { + expect(trigger.matches(buildCtx({ labelName: 'some-other-label' }))).toBe(false); + }); + + it('does not match when label name is absent (early return on falsy labelName)', () => { + // The source code checks `if (!labelName) return false` before comparing labelId, + // so missing label.name always causes non-match + const ctx = buildCtx({ labelId: 'cascade-ready', labelName: 'cascade-ready' }); + const data = ctx.payload as Record; + (data.data as Record).label = { id: 'cascade-ready', name: undefined }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match when readyToProcess label is not configured', () => { + mockResolveProjectPMConfig.mockReturnValue({ + labels: { processing: 'cascade-processing' }, // no readyToProcess + statuses: {}, + }); + expect(trigger.matches(buildCtx())).toBe(false); + }); + + it('does not match when data.label is missing', () => { + const ctx = buildCtx(); + const data = ctx.payload as Record; + (data.data as Record).label = undefined; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('matches with a custom readyToProcess label name', () => { + mockResolveProjectPMConfig.mockReturnValue({ + labels: { readyToProcess: 'my-custom-ready-label' }, + statuses: {}, + }); + expect(trigger.matches(buildCtx({ labelName: 'my-custom-ready-label' }))).toBe(true); + }); + }); + + // ========================================================================= + // handle + // ========================================================================= + describe('handle', () => { + it('returns implementation agent when issue state maps to "todo"', async () => { + const result = await trigger.handle(buildCtx({ issueStateId: 'state-todo' })); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('implementation'); + expect(result?.workItemId).toBe('TEAM-123'); + expect(result?.workItemUrl).toBe('https://linear.app/org/issue/TEAM-123'); + expect(result?.agentInput.workItemId).toBe('TEAM-123'); + expect(result?.agentInput.triggerEvent).toBe('pm:label-added'); + }); + + it('returns splitting agent when issue state maps to "splitting"', async () => { + const result = await trigger.handle(buildCtx({ issueStateId: 'state-splitting' })); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('splitting'); + }); + + it('returns planning agent when issue state maps to "planning"', async () => { + const result = await trigger.handle(buildCtx({ issueStateId: 'state-planning' })); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('planning'); + }); + + it('returns null when issue state does not map to any agent', async () => { + const result = await trigger.handle(buildCtx({ issueStateId: 'state-done' })); + expect(result).toBeNull(); + }); + + it('returns null when issue identifier is missing', async () => { + const ctx = buildCtx(); + const data = ctx.payload as Record; + (data.data as Record).issue = undefined; + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when issue stateId is missing', async () => { + const ctx = buildCtx(); + const data = ctx.payload as Record; + (data.data as Record).issue = { + id: 'issue-uuid', + identifier: 'TEAM-123', + // no stateId + }; + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when linear config has no statuses', async () => { + mockGetLinearConfig.mockReturnValue({ teamId: 'team-abc' }); // no statuses + const result = await trigger.handle(buildCtx()); + expect(result).toBeNull(); + }); + + it('returns null when linear config is missing', async () => { + mockGetLinearConfig.mockReturnValue(undefined); + const result = await trigger.handle(buildCtx()); + expect(result).toBeNull(); + }); + + it('returns null when trigger is disabled for the resolved agent', async () => { + vi.mocked(checkTriggerEnabled).mockResolvedValue(false); + + const result = await trigger.handle(buildCtx({ issueStateId: 'state-todo' })); + + expect(result).toBeNull(); + expect(checkTriggerEnabled).toHaveBeenCalledWith( + 'proj-linear', + 'implementation', + 'pm:label-added', + 'linear-ready-to-process-label-added', + ); + }); + + it('calls checkTriggerEnabled with correct args for splitting', async () => { + vi.mocked(checkTriggerEnabled).mockResolvedValue(true); + + await trigger.handle(buildCtx({ issueStateId: 'state-splitting' })); + + expect(checkTriggerEnabled).toHaveBeenCalledWith( + 'proj-linear', + 'splitting', + 'pm:label-added', + 'linear-ready-to-process-label-added', + ); + }); + + it('includes linearIssueId in agentInput', async () => { + const result = await trigger.handle( + buildCtx({ issueStateId: 'state-todo', issueId: 'issue-uuid-xyz' }), + ); + + expect(result?.agentInput.linearIssueId).toBe('issue-uuid-xyz'); + }); + + it('falls back to issue.id when identifier is missing', async () => { + const ctx = buildCtx({ issueStateId: 'state-todo' }); + const data = ctx.payload as Record; + (data.data as Record).issue = { + id: 'fallback-id', + // no identifier + stateId: 'state-todo', + url: 'https://linear.app/org/issue/fallback', + }; + const result = await trigger.handle(ctx); + expect(result?.workItemId).toBe('fallback-id'); + }); + + it('workItemTitle is undefined (not included in IssueLabel payload)', async () => { + const result = await trigger.handle(buildCtx({ issueStateId: 'state-todo' })); + expect(result?.workItemTitle).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/triggers/linear-status-changed.test.ts b/tests/unit/triggers/linear-status-changed.test.ts new file mode 100644 index 00000000..7a4c99ef --- /dev/null +++ b/tests/unit/triggers/linear-status-changed.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockLogger, mockTriggerCheckModule } from '../../helpers/sharedMocks.js'; + +vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); +vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); + +const mockGetLinearConfig = vi.fn(); +vi.mock('../../../src/pm/config.js', () => ({ + getLinearConfig: (...args: unknown[]) => mockGetLinearConfig(...args), +})); + +import { LinearStatusChangedTrigger } from '../../../src/triggers/linear/status-changed.js'; +import { checkTriggerEnabled } from '../../../src/triggers/shared/trigger-check.js'; +import type { TriggerContext } from '../../../src/types/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const baseLinearConfig = { + teamId: 'team-abc', + statuses: { + splitting: 'state-splitting', + planning: 'state-planning', + todo: 'state-todo', + backlog: 'state-backlog', + done: 'state-done', + }, +}; + +const mockProject = { + id: 'proj-linear', + orgId: 'org-1', + name: 'Linear Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'linear' as const }, + linear: baseLinearConfig, +} as TriggerContext['project']; + +function buildCtx( + overrides: { + source?: TriggerContext['source']; + action?: string; + type?: string; + newStateId?: string; + previousStateId?: string; + issueIdentifier?: string; + issueId?: string; + issueTitle?: string; + issueUrl?: string; + noUpdatedFrom?: boolean; + noLinearConfig?: boolean; + } = {}, +): TriggerContext { + const project = overrides.noLinearConfig ? { ...mockProject, linear: undefined } : mockProject; + + return { + project: project as TriggerContext['project'], + source: overrides.source ?? 'linear', + payload: { + action: overrides.action ?? 'update', + type: overrides.type ?? 'Issue', + organizationId: 'org-123', + webhookTimestamp: Date.now(), + data: { + id: overrides.issueId ?? 'issue-uuid', + identifier: overrides.issueIdentifier ?? 'TEAM-123', + title: overrides.issueTitle ?? 'Fix the bug', + url: overrides.issueUrl ?? 'https://linear.app/org/issue/TEAM-123', + stateId: overrides.newStateId ?? 'state-todo', + teamId: 'team-abc', + }, + ...(overrides.noUpdatedFrom + ? {} + : { + updatedFrom: { + stateId: overrides.previousStateId ?? 'state-backlog', + }, + }), + url: 'https://linear.app/org/issue/TEAM-123', + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('LinearStatusChangedTrigger', () => { + let trigger: LinearStatusChangedTrigger; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkTriggerEnabled).mockResolvedValue(true); + mockGetLinearConfig.mockReturnValue(baseLinearConfig); + trigger = new LinearStatusChangedTrigger(); + }); + + // ========================================================================= + // matches + // ========================================================================= + describe('matches', () => { + it('matches update/Issue events with stateId change in updatedFrom', () => { + expect(trigger.matches(buildCtx())).toBe(true); + }); + + it('does not match non-linear source', () => { + expect(trigger.matches(buildCtx({ source: 'jira' }))).toBe(false); + }); + + it('does not match non-update actions', () => { + expect(trigger.matches(buildCtx({ action: 'create' }))).toBe(false); + }); + + it('does not match non-Issue types', () => { + expect(trigger.matches(buildCtx({ type: 'Comment' }))).toBe(false); + }); + + it('does not match when updatedFrom is missing', () => { + expect(trigger.matches(buildCtx({ noUpdatedFrom: true }))).toBe(false); + }); + + it('does not match when updatedFrom.stateId is not a string', () => { + const ctx = buildCtx(); + (ctx.payload as Record).updatedFrom = { stateId: 123 }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match IssueLabel type', () => { + expect(trigger.matches(buildCtx({ type: 'IssueLabel' }))).toBe(false); + }); + }); + + // ========================================================================= + // handle + // ========================================================================= + describe('handle', () => { + it('returns implementation agent when new state maps to "todo"', async () => { + const result = await trigger.handle(buildCtx({ newStateId: 'state-todo' })); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('implementation'); + expect(result?.workItemId).toBe('TEAM-123'); + expect(result?.workItemTitle).toBe('Fix the bug'); + expect(result?.workItemUrl).toBe('https://linear.app/org/issue/TEAM-123'); + expect(result?.agentInput.workItemId).toBe('TEAM-123'); + expect(result?.agentInput.triggerEvent).toBe('pm:status-changed'); + }); + + it('returns splitting agent when new state maps to "splitting"', async () => { + const result = await trigger.handle(buildCtx({ newStateId: 'state-splitting' })); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('splitting'); + }); + + it('returns planning agent when new state maps to "planning"', async () => { + const result = await trigger.handle(buildCtx({ newStateId: 'state-planning' })); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('planning'); + }); + + it('returns backlog-manager agent when new state maps to "backlog"', async () => { + const result = await trigger.handle(buildCtx({ newStateId: 'state-backlog' })); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('backlog-manager'); + }); + + it('returns null when new state does not map to any agent', async () => { + const result = await trigger.handle(buildCtx({ newStateId: 'state-done' })); + expect(result).toBeNull(); + }); + + it('returns null when newStateId is missing from data', async () => { + const ctx = buildCtx(); + (ctx.payload as Record).data = { + identifier: 'TEAM-1', + // no stateId + }; + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when issueIdentifier is missing', async () => { + const ctx = buildCtx(); + (ctx.payload as Record).data = { + stateId: 'state-todo', + // no identifier or id + }; + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when linear config is missing statuses', async () => { + mockGetLinearConfig.mockReturnValue({ teamId: 'team-abc' }); // no statuses + const result = await trigger.handle(buildCtx()); + expect(result).toBeNull(); + }); + + it('returns null when linear config is missing entirely', async () => { + mockGetLinearConfig.mockReturnValue(undefined); + const result = await trigger.handle(buildCtx()); + expect(result).toBeNull(); + }); + + it('returns null when trigger is disabled for the resolved agent', async () => { + vi.mocked(checkTriggerEnabled).mockResolvedValue(false); + + const result = await trigger.handle(buildCtx({ newStateId: 'state-todo' })); + + expect(result).toBeNull(); + expect(checkTriggerEnabled).toHaveBeenCalledWith( + 'proj-linear', + 'implementation', + 'pm:status-changed', + 'linear-status-changed', + ); + }); + + it('calls checkTriggerEnabled with correct args for splitting agent', async () => { + vi.mocked(checkTriggerEnabled).mockResolvedValue(true); + + await trigger.handle(buildCtx({ newStateId: 'state-splitting' })); + + expect(checkTriggerEnabled).toHaveBeenCalledWith( + 'proj-linear', + 'splitting', + 'pm:status-changed', + 'linear-status-changed', + ); + }); + + it('includes linearIssueId in agentInput', async () => { + const result = await trigger.handle( + buildCtx({ newStateId: 'state-todo', issueId: 'issue-uuid-123' }), + ); + + expect(result?.agentInput.linearIssueId).toBe('issue-uuid-123'); + }); + + it('falls back to id when identifier is missing', async () => { + const ctx = buildCtx({ newStateId: 'state-todo' }); + const data = ctx.payload as Record; + (data.data as Record).identifier = undefined; + (data.data as Record).id = 'fallback-id'; + + const result = await trigger.handle(ctx); + + expect(result?.workItemId).toBe('fallback-id'); + }); + }); +});