513 lines
17 KiB
JavaScript
513 lines
17 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
|
import fs from 'fs';
|
||
|
|
import os from 'os';
|
||
|
|
import path from 'path';
|
||
|
|
import { setLinearAuth, clearLinearAuth } from './auth.js';
|
||
|
|
import { getLinearIssue, listLinearIssues, listLinearIssueStates, parseLinearIssueRef, createLinearIssueComment, updateLinearIssue } from './issues.js';
|
||
|
|
|
||
|
|
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-issues-'));
|
||
|
|
|
||
|
|
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {
|
||
|
|
status,
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
});
|
||
|
|
|
||
|
|
const issueNode = {
|
||
|
|
id: 'issue-uuid-1',
|
||
|
|
identifier: 'ENG-12',
|
||
|
|
title: 'Broken login',
|
||
|
|
url: 'https://linear.app/openchamber/issue/ENG-12',
|
||
|
|
priority: 1,
|
||
|
|
state: { id: 'state-started', name: 'In Progress', type: 'started' },
|
||
|
|
assignee: { name: 'Ada', displayName: 'Ada Lovelace', avatarUrl: 'https://example.com/a.png' },
|
||
|
|
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
|
||
|
|
labels: { nodes: [{ id: 'label-bug', name: 'Bug', color: 'EB5757' }] },
|
||
|
|
};
|
||
|
|
|
||
|
|
describe('parseLinearIssueRef', () => {
|
||
|
|
it('reads identifiers, URLs, and UUIDs', () => {
|
||
|
|
expect(parseLinearIssueRef('eng-12')).toEqual({ kind: 'identifier', value: 'ENG-12' });
|
||
|
|
expect(parseLinearIssueRef('https://linear.app/openchamber/issue/ENG-12/broken-login'))
|
||
|
|
.toEqual({ kind: 'identifier', value: 'ENG-12' });
|
||
|
|
expect(parseLinearIssueRef('11111111-2222-3333-4444-555555555555'))
|
||
|
|
.toEqual({ kind: 'id', value: '11111111-2222-3333-4444-555555555555' });
|
||
|
|
expect(parseLinearIssueRef('login redirect')).toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Linear issue list/get', () => {
|
||
|
|
let dataDir;
|
||
|
|
let previousDataDir;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
|
||
|
|
dataDir = makeTempDir();
|
||
|
|
process.env.OPENCHAMBER_DATA_DIR = dataDir;
|
||
|
|
setLinearAuth({
|
||
|
|
accessToken: 'access-1',
|
||
|
|
refreshToken: 'refresh-1',
|
||
|
|
tokenType: 'Bearer',
|
||
|
|
expiresAt: Date.now() + 86_400_000,
|
||
|
|
scope: 'read,write,comments:create',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
vi.unstubAllGlobals();
|
||
|
|
clearLinearAuth();
|
||
|
|
if (previousDataDir === undefined) {
|
||
|
|
delete process.env.OPENCHAMBER_DATA_DIR;
|
||
|
|
} else {
|
||
|
|
process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
|
||
|
|
}
|
||
|
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns disconnected without calling Linear when there is no auth', async () => {
|
||
|
|
clearLinearAuth();
|
||
|
|
const graphql = vi.fn();
|
||
|
|
vi.stubGlobal('fetch', graphql);
|
||
|
|
await expect(listLinearIssues()).resolves.toEqual({ connected: false });
|
||
|
|
expect(graphql).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('lists incomplete issues and never returns the token', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
||
|
|
const body = JSON.parse(options.body);
|
||
|
|
expect(body.query).toContain('query ListLinearIssues');
|
||
|
|
expect(body.variables.filter.state.type.nin).toEqual(['completed', 'canceled', 'duplicate']);
|
||
|
|
expect(options.headers.Authorization).toBe('Bearer access-1');
|
||
|
|
expect(options.headers['public-file-urls-expire-in']).toBe('3600');
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
issues: {
|
||
|
|
nodes: [issueNode],
|
||
|
|
pageInfo: { hasNextPage: true, endCursor: 'cursor-2' },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}));
|
||
|
|
|
||
|
|
const result = await listLinearIssues();
|
||
|
|
expect(result).toEqual({
|
||
|
|
connected: true,
|
||
|
|
issues: [{
|
||
|
|
id: 'issue-uuid-1',
|
||
|
|
identifier: 'ENG-12',
|
||
|
|
title: 'Broken login',
|
||
|
|
url: 'https://linear.app/openchamber/issue/ENG-12',
|
||
|
|
state: { id: 'state-started', name: 'In Progress', type: 'started' },
|
||
|
|
assignee: { name: 'Ada', displayName: 'Ada Lovelace', avatarUrl: 'https://example.com/a.png' },
|
||
|
|
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
|
||
|
|
priority: 1,
|
||
|
|
labels: [{ id: 'label-bug', name: 'Bug', color: '#eb5757' }],
|
||
|
|
}],
|
||
|
|
cursor: 'cursor-2',
|
||
|
|
hasMore: true,
|
||
|
|
});
|
||
|
|
expect(JSON.stringify(result)).not.toContain('access-1');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('includes priority and labels and drops invalid values', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
|
||
|
|
data: {
|
||
|
|
issues: {
|
||
|
|
nodes: [{
|
||
|
|
...issueNode,
|
||
|
|
priority: 9,
|
||
|
|
labels: {
|
||
|
|
nodes: [
|
||
|
|
{ id: 'label-ok', name: 'Bug', color: '#EB5757' },
|
||
|
|
{ id: 'label-bad-color', name: 'Nope', color: 'red' },
|
||
|
|
{ id: '', name: 'Missing id' },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
}],
|
||
|
|
pageInfo: { hasNextPage: false, endCursor: null },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})));
|
||
|
|
|
||
|
|
const result = await listLinearIssues();
|
||
|
|
expect(result.issues?.[0]?.priority).toBeNull();
|
||
|
|
expect(result.issues?.[0]?.labels).toEqual([
|
||
|
|
{ id: 'label-ok', name: 'Bug', color: '#eb5757' },
|
||
|
|
{ id: 'label-bad-color', name: 'Nope', color: null },
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('searches by text and looks up an identifier directly', async () => {
|
||
|
|
const graphql = vi.fn(async (_url, options) => {
|
||
|
|
const body = JSON.parse(options.body);
|
||
|
|
if (body.query.includes('SearchLinearIssues')) {
|
||
|
|
expect(body.variables.term).toBe('login');
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
searchIssues: {
|
||
|
|
nodes: [issueNode],
|
||
|
|
pageInfo: { hasNextPage: false, endCursor: null },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
expect(body.variables.id).toBe('ENG-12');
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
issue: {
|
||
|
|
...issueNode,
|
||
|
|
description: 'Users cannot sign in.',
|
||
|
|
comments: {
|
||
|
|
nodes: [{
|
||
|
|
id: 'comment-1',
|
||
|
|
body: 'Still broken',
|
||
|
|
createdAt: '2026-08-24T10:00:00.000Z',
|
||
|
|
user: { name: 'Ada', displayName: 'Ada Lovelace' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
vi.stubGlobal('fetch', graphql);
|
||
|
|
|
||
|
|
const search = await listLinearIssues({ query: 'login' });
|
||
|
|
expect(search.issues).toHaveLength(1);
|
||
|
|
expect(search.hasMore).toBe(false);
|
||
|
|
|
||
|
|
const byId = await listLinearIssues({ query: 'https://linear.app/openchamber/issue/ENG-12' });
|
||
|
|
expect(byId.issues?.[0]?.identifier).toBe('ENG-12');
|
||
|
|
expect(byId.hasMore).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('applies status, assignee, team, and priority list filters', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
||
|
|
const body = JSON.parse(options.body);
|
||
|
|
expect(body.variables.filter).toEqual({
|
||
|
|
state: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } },
|
||
|
|
assignee: { isMe: { eq: true } },
|
||
|
|
team: { id: { eq: 'team-eng' } },
|
||
|
|
priority: { eq: 1 },
|
||
|
|
});
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
issues: {
|
||
|
|
nodes: [issueNode],
|
||
|
|
pageInfo: { hasNextPage: false, endCursor: null },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}));
|
||
|
|
|
||
|
|
const result = await listLinearIssues({
|
||
|
|
status: 'started',
|
||
|
|
assignee: 'me',
|
||
|
|
teamId: 'team-eng',
|
||
|
|
priority: 'urgent',
|
||
|
|
});
|
||
|
|
expect(result.issues).toHaveLength(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('filters each panel status to a Linear state type or name', async () => {
|
||
|
|
const filters = [];
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
||
|
|
filters.push(JSON.parse(options.body).variables.filter);
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
issues: {
|
||
|
|
nodes: [issueNode],
|
||
|
|
pageInfo: { hasNextPage: false, endCursor: null },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}));
|
||
|
|
|
||
|
|
await listLinearIssues({ status: 'todo' });
|
||
|
|
await listLinearIssues({ status: 'backlog' });
|
||
|
|
await listLinearIssues({ status: 'started' });
|
||
|
|
await listLinearIssues({ status: 'inReview' });
|
||
|
|
await listLinearIssues({ status: 'completed' });
|
||
|
|
await listLinearIssues({ status: 'canceled' });
|
||
|
|
await listLinearIssues({ status: 'duplicate' });
|
||
|
|
expect(filters).toEqual([
|
||
|
|
{ state: { type: { eq: 'unstarted' } } },
|
||
|
|
{ state: { type: { eq: 'backlog' } } },
|
||
|
|
{ state: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } } },
|
||
|
|
{ state: { name: { eqIgnoreCase: 'In Review' } } },
|
||
|
|
{ state: { type: { eq: 'completed' } } },
|
||
|
|
{ state: { type: { eq: 'canceled' }, name: { neqIgnoreCase: 'Duplicate' } } },
|
||
|
|
{ state: { or: [{ type: { eq: 'duplicate' } }, { name: { eqIgnoreCase: 'Duplicate' } }] } },
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('omits the state filter when listing all issues', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
||
|
|
const body = JSON.parse(options.body);
|
||
|
|
expect(body.variables.filter).toBeUndefined();
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
issues: {
|
||
|
|
nodes: [issueNode],
|
||
|
|
pageInfo: { hasNextPage: false, endCursor: null },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}));
|
||
|
|
|
||
|
|
await listLinearIssues({ status: 'all' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('filters no-priority issues as Linear priority 0', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
||
|
|
const body = JSON.parse(options.body);
|
||
|
|
expect(body.variables.filter).toEqual({
|
||
|
|
priority: { eq: 0 },
|
||
|
|
});
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
issues: {
|
||
|
|
nodes: [issueNode],
|
||
|
|
pageInfo: { hasNextPage: false, endCursor: null },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}));
|
||
|
|
|
||
|
|
await listLinearIssues({ status: 'all', priority: 'none' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('looks up an identifier without applying list filters', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
||
|
|
const body = JSON.parse(options.body);
|
||
|
|
expect(body.query).toContain('GetLinearIssue');
|
||
|
|
expect(body.variables.id).toBe('ENG-12');
|
||
|
|
expect(body.variables.filter).toBeUndefined();
|
||
|
|
return jsonResponse({ data: { issue: issueNode } });
|
||
|
|
}));
|
||
|
|
|
||
|
|
const result = await listLinearIssues({
|
||
|
|
query: 'ENG-12',
|
||
|
|
status: 'completed',
|
||
|
|
assignee: 'me',
|
||
|
|
teamId: 'team-eng',
|
||
|
|
priority: 'urgent',
|
||
|
|
});
|
||
|
|
expect(result.issues?.[0]?.identifier).toBe('ENG-12');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('loads one issue with comments', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
|
||
|
|
data: {
|
||
|
|
issue: {
|
||
|
|
...issueNode,
|
||
|
|
description: 'Users cannot sign in.',
|
||
|
|
comments: { nodes: [{ id: 'comment-1', body: 'Still broken', createdAt: '2026-08-24T10:00:00.000Z', user: { name: 'Ada', displayName: null, avatarUrl: 'https://linear.app/avatar/ada.png' } }] },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})));
|
||
|
|
|
||
|
|
const result = await getLinearIssue('ENG-12');
|
||
|
|
expect(result.connected).toBe(true);
|
||
|
|
expect(result.issue?.description).toBe('Users cannot sign in.');
|
||
|
|
expect(result.issue?.priority).toBe(1);
|
||
|
|
expect(result.issue?.labels).toEqual([{ id: 'label-bug', name: 'Bug', color: '#eb5757' }]);
|
||
|
|
expect(result.issue?.comments).toEqual([{
|
||
|
|
id: 'comment-1',
|
||
|
|
body: 'Still broken',
|
||
|
|
createdAt: '2026-08-24T10:00:00.000Z',
|
||
|
|
user: { name: 'Ada', displayName: null, avatarUrl: 'https://linear.app/avatar/ada.png' },
|
||
|
|
}]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('creates a comment on the resolved issue UUID', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
||
|
|
const body = JSON.parse(options.body);
|
||
|
|
if (body.query.includes('query GetLinearIssue')) {
|
||
|
|
expect(body.variables.id).toBe('ENG-12');
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
issue: {
|
||
|
|
...issueNode,
|
||
|
|
description: null,
|
||
|
|
comments: { nodes: [] },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
expect(body.query).toContain('mutation CommentCreate');
|
||
|
|
expect(body.variables.input).toEqual({
|
||
|
|
issueId: 'issue-uuid-1',
|
||
|
|
body: 'OpenChamber session started.',
|
||
|
|
});
|
||
|
|
expect(options.headers.Authorization).toBe('Bearer access-1');
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
commentCreate: {
|
||
|
|
success: true,
|
||
|
|
comment: { id: 'comment-9' },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}));
|
||
|
|
|
||
|
|
const result = await createLinearIssueComment({
|
||
|
|
issueId: 'ENG-12',
|
||
|
|
body: 'OpenChamber session started.',
|
||
|
|
});
|
||
|
|
expect(result).toEqual({ connected: true, comment: { id: 'comment-9' } });
|
||
|
|
expect(JSON.stringify(result)).not.toContain('access-1');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('clears auth and reports disconnected after a GraphQL 401', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ errors: [{ message: 'Unauthorized' }] }, 401)));
|
||
|
|
await expect(listLinearIssues()).resolves.toEqual({ connected: false });
|
||
|
|
await expect(listLinearIssues()).resolves.toEqual({ connected: false });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('lists team workflow states in Linear workflow order', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
||
|
|
const body = JSON.parse(options.body);
|
||
|
|
expect(body.query).toContain('query TeamWorkflowStates');
|
||
|
|
expect(body.variables.id).toBe('team-eng');
|
||
|
|
expect(options.headers.Authorization).toBe('Bearer access-1');
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
team: {
|
||
|
|
states: {
|
||
|
|
nodes: [
|
||
|
|
{ id: 'state-done', name: 'Done', type: 'completed', position: 0 },
|
||
|
|
{ id: 'state-review', name: 'In Review', type: 'started', position: 1 },
|
||
|
|
{ id: 'state-todo', name: 'Todo', type: 'unstarted', position: 0 },
|
||
|
|
{ id: 'state-dup', name: 'Duplicate', type: 'canceled', position: 1 },
|
||
|
|
{ id: 'state-progress', name: 'In Progress', type: 'started', position: 0 },
|
||
|
|
{ id: 'state-backlog', name: 'Backlog', type: 'backlog', position: 0 },
|
||
|
|
{ id: 'state-canceled', name: 'Canceled', type: 'canceled', position: 0 },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}));
|
||
|
|
|
||
|
|
const result = await listLinearIssueStates('team-eng');
|
||
|
|
expect(result.states?.map((state) => state.name)).toEqual([
|
||
|
|
'Backlog',
|
||
|
|
'Todo',
|
||
|
|
'In Progress',
|
||
|
|
'In Review',
|
||
|
|
'Done',
|
||
|
|
'Canceled',
|
||
|
|
'Duplicate',
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects workflow states without a team id', async () => {
|
||
|
|
await expect(listLinearIssueStates('')).rejects.toMatchObject({
|
||
|
|
message: 'teamId is required',
|
||
|
|
code: 'INVALID',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('updates an issue state and returns the issue', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
||
|
|
const body = JSON.parse(options.body);
|
||
|
|
expect(body.query).toContain('mutation IssueUpdate');
|
||
|
|
expect(body.variables).toEqual({
|
||
|
|
id: 'issue-uuid-1',
|
||
|
|
input: { stateId: 'state-done' },
|
||
|
|
});
|
||
|
|
expect(options.headers.Authorization).toBe('Bearer access-1');
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
issueUpdate: {
|
||
|
|
success: true,
|
||
|
|
issue: {
|
||
|
|
...issueNode,
|
||
|
|
state: { id: 'state-done', name: 'Done', type: 'completed' },
|
||
|
|
description: null,
|
||
|
|
comments: { nodes: [] },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}));
|
||
|
|
|
||
|
|
const result = await updateLinearIssue({ id: 'issue-uuid-1', stateId: 'state-done' });
|
||
|
|
expect(result.connected).toBe(true);
|
||
|
|
expect(result.issue?.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' });
|
||
|
|
expect(JSON.stringify(result)).not.toContain('access-1');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects an issue update without id or stateId', async () => {
|
||
|
|
await expect(updateLinearIssue({ id: 'issue-uuid-1' })).rejects.toMatchObject({
|
||
|
|
message: 'id and stateId are required',
|
||
|
|
code: 'INVALID',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('resolves an issue identifier before issueUpdate', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
|
||
|
|
const body = JSON.parse(options.body);
|
||
|
|
if (body.query.includes('query GetLinearIssue')) {
|
||
|
|
expect(body.variables.id).toBe('ENG-12');
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
issue: {
|
||
|
|
...issueNode,
|
||
|
|
description: null,
|
||
|
|
comments: { nodes: [] },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
expect(body.query).toContain('mutation IssueUpdate');
|
||
|
|
expect(body.variables).toEqual({
|
||
|
|
id: 'issue-uuid-1',
|
||
|
|
input: { stateId: 'state-done' },
|
||
|
|
});
|
||
|
|
return jsonResponse({
|
||
|
|
data: {
|
||
|
|
issueUpdate: {
|
||
|
|
success: true,
|
||
|
|
issue: {
|
||
|
|
...issueNode,
|
||
|
|
state: { id: 'state-done', name: 'Done', type: 'completed' },
|
||
|
|
description: null,
|
||
|
|
comments: { nodes: [] },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}));
|
||
|
|
|
||
|
|
const result = await updateLinearIssue({ id: 'ENG-12', stateId: 'state-done' });
|
||
|
|
expect(result.connected).toBe(true);
|
||
|
|
expect(result.issue?.id).toBe('issue-uuid-1');
|
||
|
|
expect(result.issue?.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('surfaces Linear validation constraints from GraphQL errors', async () => {
|
||
|
|
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
|
||
|
|
data: null,
|
||
|
|
errors: [{
|
||
|
|
message: 'Argument Validation Error',
|
||
|
|
extensions: {
|
||
|
|
code: 'INVALID_INPUT',
|
||
|
|
userError: true,
|
||
|
|
userPresentableMessage: 'stateId must be a UUID.',
|
||
|
|
validationErrors: [{
|
||
|
|
property: 'stateId',
|
||
|
|
constraints: { isUuid: 'stateId must be a UUID.' },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
}],
|
||
|
|
})));
|
||
|
|
|
||
|
|
await expect(updateLinearIssue({ id: 'issue-uuid-1', stateId: 'not-a-uuid' })).rejects.toMatchObject({
|
||
|
|
name: 'LinearApiError',
|
||
|
|
message: 'stateId must be a UUID.',
|
||
|
|
status: 400,
|
||
|
|
userError: true,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|