mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
520 lines
16 KiB
TypeScript
520 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { LinearClientForWorkspace } from './client'
|
|
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
|
|
|
|
const rawRequest = vi.fn()
|
|
const getClients = vi.fn()
|
|
const clearToken = vi.fn()
|
|
const isAuthError = vi.fn()
|
|
|
|
vi.mock('./linear-request-concurrency', () => ({
|
|
acquire: vi.fn().mockResolvedValue(undefined),
|
|
release: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./linear-token-store', () => ({
|
|
clearToken: (...args: unknown[]) => clearToken(...args)
|
|
}))
|
|
|
|
vi.mock('./client', () => ({
|
|
getClients: (...args: unknown[]) => getClients(...args),
|
|
isAuthError: (...args: unknown[]) => isAuthError(...args)
|
|
}))
|
|
|
|
function makeEntry(): LinearClientForWorkspace {
|
|
return {
|
|
workspace: {
|
|
id: 'workspace-1',
|
|
organizationId: 'workspace-1',
|
|
organizationName: 'Workspace',
|
|
displayName: 'Ada',
|
|
email: 'ada@example.com'
|
|
},
|
|
client: {
|
|
client: { rawRequest }
|
|
}
|
|
} as unknown as LinearClientForWorkspace
|
|
}
|
|
|
|
function rawIssue(id: string) {
|
|
return {
|
|
id,
|
|
identifier: id,
|
|
title: id,
|
|
url: `https://linear.app/${id}`,
|
|
priority: 0,
|
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
labelIds: [],
|
|
state: { name: 'Todo', type: 'unstarted', color: '#888888' },
|
|
team: { id: 'team-1', name: 'Team', key: 'TM' },
|
|
labels: { nodes: [] }
|
|
}
|
|
}
|
|
|
|
function rawProject(id: string) {
|
|
return {
|
|
id,
|
|
name: id
|
|
}
|
|
}
|
|
|
|
function rawProjectWithName(id: string, name: string) {
|
|
return {
|
|
...rawProject(id),
|
|
name
|
|
}
|
|
}
|
|
|
|
function projectSearchConnectionResponse(
|
|
projects: ReturnType<typeof rawProject>[],
|
|
pageInfo: { hasNextPage: boolean; endCursor?: string | null } = { hasNextPage: false }
|
|
) {
|
|
return {
|
|
data: {
|
|
searchProjects: {
|
|
nodes: projects,
|
|
pageInfo
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function rawCustomView(id: string) {
|
|
return {
|
|
id,
|
|
name: id,
|
|
modelName: 'Project'
|
|
}
|
|
}
|
|
|
|
function projectIssuesResponse(issueId: string) {
|
|
return projectIssuesConnectionResponse([issueId])
|
|
}
|
|
|
|
function projectIssuesConnectionResponse(
|
|
issueIds: string[],
|
|
pageInfo: { hasNextPage: boolean; endCursor?: string | null } = { hasNextPage: false }
|
|
) {
|
|
return {
|
|
data: {
|
|
project: {
|
|
issues: {
|
|
nodes: issueIds.map((issueId) => rawIssue(issueId)),
|
|
pageInfo
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function projectTeamsConnectionResponse(
|
|
teamIds: string[],
|
|
pageInfo: { hasNextPage: boolean; endCursor?: string | null } = { hasNextPage: false }
|
|
) {
|
|
return {
|
|
data: {
|
|
project: {
|
|
teams: {
|
|
nodes: teamIds.map((teamId) => ({ id: teamId, name: teamId, key: teamId })),
|
|
pageInfo
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function customViewsResponse(viewId: string) {
|
|
return {
|
|
data: {
|
|
customViews: {
|
|
nodes: [rawCustomView(viewId)],
|
|
pageInfo: { hasNextPage: false }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function customViewResponse(viewId: string) {
|
|
return {
|
|
data: {
|
|
customView: rawCustomView(viewId)
|
|
}
|
|
}
|
|
}
|
|
|
|
function customViewProjectsResponse(projectId: string) {
|
|
return {
|
|
data: {
|
|
customView: {
|
|
modelName: 'Project',
|
|
projects: {
|
|
nodes: [rawProject(projectId)],
|
|
pageInfo: { hasNextPage: false }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function customViewIssuesResponse(issueId: string) {
|
|
return customViewIssuesConnectionResponse([issueId])
|
|
}
|
|
|
|
function customViewIssuesConnectionResponse(
|
|
issueIds: string[],
|
|
pageInfo: { hasNextPage: boolean; endCursor?: string | null } = { hasNextPage: false }
|
|
) {
|
|
return {
|
|
data: {
|
|
customView: {
|
|
modelName: 'Issue',
|
|
issues: {
|
|
nodes: issueIds.map((issueId) => rawIssue(issueId)),
|
|
pageInfo
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void
|
|
const promise = new Promise<T>((res) => {
|
|
resolve = res
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
|
|
describe('Linear project queries', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
isAuthError.mockReturnValue(false)
|
|
getClients.mockReturnValue([makeEntry()])
|
|
})
|
|
|
|
it('surfaces Linear credential decrypt errors on active project metadata reads', async () => {
|
|
const error = new Error(credentialDecryptionMessage('Linear'))
|
|
getClients.mockImplementation(() => {
|
|
throw error
|
|
})
|
|
const { listProjects } = await import('./projects')
|
|
|
|
await expect(listProjects(undefined, 20, 'workspace-1', true)).rejects.toThrow(error.message)
|
|
})
|
|
|
|
it('lets manual project issue refresh bypass older in-flight reads', async () => {
|
|
const staleRequest = deferred<ReturnType<typeof projectIssuesResponse>>()
|
|
const refreshRequest = deferred<ReturnType<typeof projectIssuesResponse>>()
|
|
rawRequest.mockReturnValueOnce(staleRequest.promise).mockReturnValueOnce(refreshRequest.promise)
|
|
const { listProjectIssues } = await import('./projects')
|
|
|
|
const stalePromise = listProjectIssues('project-1', 20, 'workspace-1')
|
|
const refreshPromise = listProjectIssues('project-1', 20, 'workspace-1', true)
|
|
|
|
await vi.waitFor(() => expect(rawRequest).toHaveBeenCalledTimes(2))
|
|
|
|
refreshRequest.resolve(projectIssuesResponse('LIN-FRESH'))
|
|
await expect(refreshPromise).resolves.toMatchObject({
|
|
items: [{ id: 'LIN-FRESH' }]
|
|
})
|
|
|
|
staleRequest.resolve(projectIssuesResponse('LIN-STALE'))
|
|
await expect(stalePromise).resolves.toMatchObject({
|
|
items: [{ id: 'LIN-STALE' }]
|
|
})
|
|
})
|
|
|
|
it('loads project issue reads above Linear connection page size', async () => {
|
|
rawRequest
|
|
.mockResolvedValueOnce(
|
|
projectIssuesConnectionResponse(
|
|
Array.from({ length: 50 }, (_, index) => `LIN-${index + 1}`),
|
|
{ hasNextPage: true, endCursor: 'project-cursor-50' }
|
|
)
|
|
)
|
|
.mockResolvedValueOnce(
|
|
projectIssuesConnectionResponse(
|
|
Array.from({ length: 50 }, (_, index) => `LIN-${index + 51}`),
|
|
{ hasNextPage: true, endCursor: 'project-cursor-100' }
|
|
)
|
|
)
|
|
.mockResolvedValueOnce(
|
|
projectIssuesConnectionResponse(
|
|
Array.from({ length: 20 }, (_, index) => `LIN-${index + 101}`),
|
|
{ hasNextPage: false }
|
|
)
|
|
)
|
|
const { listProjectIssues } = await import('./projects')
|
|
|
|
const result = await listProjectIssues('project-1', 120, 'workspace-1')
|
|
|
|
expect(result.items).toHaveLength(120)
|
|
expect(result.hasMore).toBe(false)
|
|
expect(rawRequest).toHaveBeenCalledTimes(3)
|
|
expect(rawRequest.mock.calls[0]?.[1]).toMatchObject({ id: 'project-1', first: 50 })
|
|
expect(rawRequest.mock.calls[0]?.[1]).not.toHaveProperty('after')
|
|
expect(rawRequest.mock.calls[1]?.[1]).toMatchObject({
|
|
id: 'project-1',
|
|
first: 50,
|
|
after: 'project-cursor-50'
|
|
})
|
|
expect(rawRequest.mock.calls[2]?.[1]).toMatchObject({
|
|
id: 'project-1',
|
|
first: 20,
|
|
after: 'project-cursor-100'
|
|
})
|
|
})
|
|
|
|
it('loads project teams above Linear connection page size', async () => {
|
|
rawRequest
|
|
.mockResolvedValueOnce(
|
|
projectTeamsConnectionResponse(
|
|
Array.from({ length: 50 }, (_, index) => `TEAM-${index + 1}`),
|
|
{ hasNextPage: true, endCursor: 'team-cursor-50' }
|
|
)
|
|
)
|
|
.mockResolvedValueOnce(projectTeamsConnectionResponse(['TEAM-51'], { hasNextPage: false }))
|
|
const { listProjectTeams } = await import('./projects')
|
|
|
|
const result = await listProjectTeams('project-1', 'workspace-1', true)
|
|
|
|
expect(result).toHaveLength(51)
|
|
expect(result.at(-1)).toMatchObject({ id: 'TEAM-51', key: 'TEAM-51' })
|
|
expect(rawRequest.mock.calls[0]?.[1]).toMatchObject({ id: 'project-1', first: 50 })
|
|
expect(rawRequest.mock.calls[0]?.[1]).not.toHaveProperty('after')
|
|
expect(rawRequest.mock.calls[1]?.[1]).toMatchObject({
|
|
id: 'project-1',
|
|
first: 50,
|
|
after: 'team-cursor-50'
|
|
})
|
|
})
|
|
|
|
it('loads exact project name matches beyond the first search page', async () => {
|
|
rawRequest
|
|
.mockResolvedValueOnce(
|
|
projectSearchConnectionResponse(
|
|
Array.from({ length: 50 }, (_, index) =>
|
|
rawProjectWithName(`project-${index + 1}`, `Other ${index + 1}`)
|
|
),
|
|
{ hasNextPage: true, endCursor: 'project-cursor-50' }
|
|
)
|
|
)
|
|
.mockResolvedValueOnce(
|
|
projectSearchConnectionResponse([
|
|
rawProjectWithName('project-launch', 'Launch'),
|
|
rawProjectWithName('project-launch-lower', 'launch')
|
|
])
|
|
)
|
|
const { listProjectsByExactName } = await import('./projects')
|
|
|
|
const result = await listProjectsByExactName('Launch', 'workspace-1', true)
|
|
|
|
expect(result).toMatchObject([
|
|
{ id: 'project-launch', name: 'Launch' },
|
|
{ id: 'project-launch-lower', name: 'launch' }
|
|
])
|
|
expect(rawRequest).toHaveBeenCalledTimes(2)
|
|
expect(rawRequest.mock.calls[0]?.[1]).toMatchObject({
|
|
term: 'Launch',
|
|
first: 50
|
|
})
|
|
expect(rawRequest.mock.calls[0]?.[1]).not.toHaveProperty('after')
|
|
expect(rawRequest.mock.calls[1]?.[1]).toMatchObject({
|
|
term: 'Launch',
|
|
first: 50,
|
|
after: 'project-cursor-50'
|
|
})
|
|
})
|
|
|
|
it('creates a project with team metadata and maps the created project', async () => {
|
|
rawRequest.mockResolvedValueOnce({
|
|
data: {
|
|
projectCreate: {
|
|
success: true,
|
|
project: {
|
|
...rawProject('project-1'),
|
|
description: 'Summary',
|
|
content: 'Brief',
|
|
priority: 2,
|
|
targetDate: '2026-08-01',
|
|
teams: { nodes: [{ id: 'team-1', name: 'Team', key: 'TM' }] }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
const { createProject } = await import('./projects')
|
|
|
|
const result = await createProject(
|
|
{
|
|
name: 'Roadmap',
|
|
description: 'Summary',
|
|
content: 'Brief',
|
|
teamIds: ['team-1'],
|
|
leadId: 'user-1',
|
|
memberIds: ['user-1', 'user-2'],
|
|
labelIds: ['label-1'],
|
|
priority: 2,
|
|
startDate: '2026-07-01',
|
|
targetDate: '2026-08-01'
|
|
},
|
|
'workspace-1'
|
|
)
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
project: {
|
|
id: 'project-1',
|
|
name: 'project-1',
|
|
workspaceId: 'workspace-1',
|
|
description: 'Summary',
|
|
content: 'Brief',
|
|
priority: 2,
|
|
targetDate: '2026-08-01',
|
|
teams: [{ id: 'team-1', name: 'Team', key: 'TM' }]
|
|
}
|
|
})
|
|
expect(rawRequest.mock.calls[0]?.[1]).toEqual({
|
|
input: {
|
|
name: 'Roadmap',
|
|
description: 'Summary',
|
|
content: 'Brief',
|
|
teamIds: ['team-1'],
|
|
leadId: 'user-1',
|
|
memberIds: ['user-1', 'user-2'],
|
|
labelIds: ['label-1'],
|
|
priority: 2,
|
|
startDate: '2026-07-01',
|
|
targetDate: '2026-08-01'
|
|
}
|
|
})
|
|
expect(rawRequest.mock.calls[0]?.[0]).toContain('mutation OrcaLinearProjectCreate')
|
|
expect(rawRequest.mock.calls[0]?.[0]).toContain('projectCreate(input: $input)')
|
|
})
|
|
|
|
it('lets manual custom view list refresh bypass older in-flight reads', async () => {
|
|
const staleRequest = deferred<ReturnType<typeof customViewsResponse>>()
|
|
const refreshRequest = deferred<ReturnType<typeof customViewsResponse>>()
|
|
rawRequest.mockReturnValueOnce(staleRequest.promise).mockReturnValueOnce(refreshRequest.promise)
|
|
const { listCustomViews } = await import('./projects')
|
|
|
|
const stalePromise = listCustomViews('project', 20, 'workspace-1')
|
|
const refreshPromise = listCustomViews('project', 20, 'workspace-1', true)
|
|
|
|
await vi.waitFor(() => expect(rawRequest).toHaveBeenCalledTimes(2))
|
|
|
|
refreshRequest.resolve(customViewsResponse('VIEW-FRESH'))
|
|
await expect(refreshPromise).resolves.toMatchObject({
|
|
items: [{ id: 'VIEW-FRESH' }]
|
|
})
|
|
|
|
staleRequest.resolve(customViewsResponse('VIEW-STALE'))
|
|
await expect(stalePromise).resolves.toMatchObject({
|
|
items: [{ id: 'VIEW-STALE' }]
|
|
})
|
|
})
|
|
|
|
it('lets forced exact custom view reads bypass older in-flight reads', async () => {
|
|
const staleRequest = deferred<ReturnType<typeof customViewResponse>>()
|
|
const refreshRequest = deferred<ReturnType<typeof customViewResponse>>()
|
|
rawRequest.mockReturnValueOnce(staleRequest.promise).mockReturnValueOnce(refreshRequest.promise)
|
|
const { getCustomView } = await import('./projects')
|
|
|
|
const stalePromise = getCustomView('view-1', 'project', 'workspace-1')
|
|
const refreshPromise = getCustomView('view-1', 'project', 'workspace-1', true)
|
|
|
|
await vi.waitFor(() => expect(rawRequest).toHaveBeenCalledTimes(2))
|
|
|
|
refreshRequest.resolve(customViewResponse('VIEW-FRESH'))
|
|
await expect(refreshPromise).resolves.toMatchObject({ id: 'VIEW-FRESH' })
|
|
|
|
staleRequest.resolve(customViewResponse('VIEW-STALE'))
|
|
await expect(stalePromise).resolves.toMatchObject({ id: 'VIEW-STALE' })
|
|
})
|
|
|
|
it('lets manual custom view project refresh bypass older in-flight reads', async () => {
|
|
const staleRequest = deferred<ReturnType<typeof customViewProjectsResponse>>()
|
|
const refreshRequest = deferred<ReturnType<typeof customViewProjectsResponse>>()
|
|
rawRequest.mockReturnValueOnce(staleRequest.promise).mockReturnValueOnce(refreshRequest.promise)
|
|
const { listCustomViewProjects } = await import('./projects')
|
|
|
|
const stalePromise = listCustomViewProjects('view-1', 20, 'workspace-1')
|
|
const refreshPromise = listCustomViewProjects('view-1', 20, 'workspace-1', true)
|
|
|
|
await vi.waitFor(() => expect(rawRequest).toHaveBeenCalledTimes(2))
|
|
|
|
refreshRequest.resolve(customViewProjectsResponse('PROJECT-FRESH'))
|
|
await expect(refreshPromise).resolves.toMatchObject({
|
|
items: [{ id: 'PROJECT-FRESH' }]
|
|
})
|
|
|
|
staleRequest.resolve(customViewProjectsResponse('PROJECT-STALE'))
|
|
await expect(stalePromise).resolves.toMatchObject({
|
|
items: [{ id: 'PROJECT-STALE' }]
|
|
})
|
|
})
|
|
|
|
it('lets manual custom view issue refresh bypass older in-flight reads', async () => {
|
|
const staleRequest = deferred<ReturnType<typeof customViewIssuesResponse>>()
|
|
const refreshRequest = deferred<ReturnType<typeof customViewIssuesResponse>>()
|
|
rawRequest.mockReturnValueOnce(staleRequest.promise).mockReturnValueOnce(refreshRequest.promise)
|
|
const { listCustomViewIssues } = await import('./projects')
|
|
|
|
const stalePromise = listCustomViewIssues('view-1', 20, 'workspace-1')
|
|
const refreshPromise = listCustomViewIssues('view-1', 20, 'workspace-1', true)
|
|
|
|
await vi.waitFor(() => expect(rawRequest).toHaveBeenCalledTimes(2))
|
|
|
|
refreshRequest.resolve(customViewIssuesResponse('ISSUE-FRESH'))
|
|
await expect(refreshPromise).resolves.toMatchObject({
|
|
items: [{ id: 'ISSUE-FRESH' }]
|
|
})
|
|
|
|
staleRequest.resolve(customViewIssuesResponse('ISSUE-STALE'))
|
|
await expect(stalePromise).resolves.toMatchObject({
|
|
items: [{ id: 'ISSUE-STALE' }]
|
|
})
|
|
})
|
|
|
|
it('loads issue custom view reads above Linear connection page size', async () => {
|
|
rawRequest
|
|
.mockResolvedValueOnce(
|
|
customViewIssuesConnectionResponse(
|
|
Array.from({ length: 50 }, (_, index) => `ISSUE-${index + 1}`),
|
|
{ hasNextPage: true, endCursor: 'view-cursor-50' }
|
|
)
|
|
)
|
|
.mockResolvedValueOnce(
|
|
customViewIssuesConnectionResponse(
|
|
Array.from({ length: 50 }, (_, index) => `ISSUE-${index + 51}`),
|
|
{ hasNextPage: true, endCursor: 'view-cursor-100' }
|
|
)
|
|
)
|
|
.mockResolvedValueOnce(
|
|
customViewIssuesConnectionResponse(
|
|
Array.from({ length: 20 }, (_, index) => `ISSUE-${index + 101}`),
|
|
{ hasNextPage: false }
|
|
)
|
|
)
|
|
const { listCustomViewIssues } = await import('./projects')
|
|
|
|
const result = await listCustomViewIssues('view-1', 120, 'workspace-1')
|
|
|
|
expect(result.items).toHaveLength(120)
|
|
expect(result.hasMore).toBe(false)
|
|
expect(rawRequest).toHaveBeenCalledTimes(3)
|
|
expect(rawRequest.mock.calls[0]?.[1]).toMatchObject({ id: 'view-1', first: 50 })
|
|
expect(rawRequest.mock.calls[0]?.[1]).not.toHaveProperty('after')
|
|
expect(rawRequest.mock.calls[1]?.[1]).toMatchObject({
|
|
id: 'view-1',
|
|
first: 50,
|
|
after: 'view-cursor-50'
|
|
})
|
|
expect(rawRequest.mock.calls[2]?.[1]).toMatchObject({
|
|
id: 'view-1',
|
|
first: 20,
|
|
after: 'view-cursor-100'
|
|
})
|
|
})
|
|
})
|