mirror of
https://github.com/schpet/linear-cli.git
synced 2026-09-14 14:26:50 +08:00
f86356d435
Fixes #183
187 lines
4.8 KiB
TypeScript
187 lines
4.8 KiB
TypeScript
/**
|
|
* Mock Linear API server for testing
|
|
*
|
|
* Usage:
|
|
* const server = new MockLinearServer([
|
|
* {
|
|
* queryName: "GetIssueDetails",
|
|
* variables: { id: "TEST-123" },
|
|
* response: { data: { issue: { title: "Test Issue", ... } } }
|
|
* }
|
|
* ]);
|
|
*/
|
|
|
|
interface MockResponse {
|
|
queryName: string
|
|
queryIncludes?: string
|
|
variables?: Record<string, unknown>
|
|
response: Record<string, unknown>
|
|
status?: number
|
|
}
|
|
|
|
export class MockLinearServer {
|
|
private server?: Deno.HttpServer
|
|
private port = 0
|
|
private mockResponses: MockResponse[]
|
|
|
|
constructor(responses: MockResponse[] = []) {
|
|
this.mockResponses = responses
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
this.server = Deno.serve({
|
|
hostname: "127.0.0.1",
|
|
port: this.port,
|
|
onListen: () => {},
|
|
}, (request) => {
|
|
// Handle CORS preflight
|
|
if (request.method === "OPTIONS") {
|
|
return new Response(null, {
|
|
status: 200,
|
|
headers: {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
},
|
|
})
|
|
}
|
|
|
|
// Handle GraphQL requests
|
|
if (
|
|
request.method === "POST" &&
|
|
new URL(request.url).pathname === "/graphql"
|
|
) {
|
|
return this.handleGraphQL(request)
|
|
}
|
|
|
|
return new Response("Not Found", { status: 404 })
|
|
})
|
|
|
|
if ("port" in this.server.addr) {
|
|
this.port = this.server.addr.port
|
|
}
|
|
|
|
// Wait a bit for server to start
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
if (this.server) {
|
|
await this.server.shutdown()
|
|
this.server = undefined
|
|
}
|
|
}
|
|
|
|
getEndpoint(): string {
|
|
return `http://localhost:${this.port}/graphql`
|
|
}
|
|
|
|
private async handleGraphQL(request: Request): Promise<Response> {
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
"Access-Control-Allow-Origin": "*",
|
|
// Use fixed date header for deterministic snapshot tests
|
|
"Date": "Mon, 01 Jan 2024 00:00:00 GMT",
|
|
}
|
|
|
|
try {
|
|
const body = await request.json()
|
|
const { query, variables } = body
|
|
|
|
// Find matching mock response
|
|
const mockResponse = this.findMatchingResponse(query, variables)
|
|
|
|
if (mockResponse) {
|
|
return new Response(
|
|
JSON.stringify(mockResponse.response),
|
|
{ status: mockResponse.status ?? 200, headers },
|
|
)
|
|
}
|
|
|
|
// Default response for unhandled queries
|
|
return new Response(
|
|
JSON.stringify({
|
|
errors: [{
|
|
message: "No mock response configured for this query",
|
|
extensions: {
|
|
code: "NO_MOCK_CONFIGURED",
|
|
query: this.extractQueryName(query),
|
|
variables,
|
|
},
|
|
}],
|
|
}),
|
|
{ status: 200, headers },
|
|
)
|
|
} catch (_error) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
errors: [{
|
|
message: "Invalid JSON in request body",
|
|
extensions: { code: "BAD_REQUEST" },
|
|
}],
|
|
}),
|
|
{ status: 400, headers },
|
|
)
|
|
}
|
|
}
|
|
|
|
private findMatchingResponse(
|
|
query: string,
|
|
variables: Record<string, unknown> = {},
|
|
): MockResponse | undefined {
|
|
const queryName = this.extractQueryName(query)
|
|
|
|
return this.mockResponses.find((mock) => {
|
|
// Check if query name matches
|
|
if (mock.queryName !== queryName) {
|
|
return false
|
|
}
|
|
|
|
if (mock.queryIncludes != null && !query.includes(mock.queryIncludes)) {
|
|
return false
|
|
}
|
|
|
|
// If no variables specified in mock, match any variables
|
|
if (!mock.variables) {
|
|
return true
|
|
}
|
|
|
|
// Check if all mock variables match the request variables (deep comparison)
|
|
return Object.entries(mock.variables).every(([key, value]) => {
|
|
return this.deepEqual(variables[key], value)
|
|
})
|
|
})
|
|
}
|
|
|
|
private deepEqual(a: unknown, b: unknown): boolean {
|
|
if (a === b) return true
|
|
if (a == null || b == null) return a === b
|
|
if (typeof a !== typeof b) return false
|
|
if (typeof a !== "object") return a === b
|
|
|
|
const aObj = a as Record<string, unknown>
|
|
const bObj = b as Record<string, unknown>
|
|
const aKeys = Object.keys(aObj)
|
|
const bKeys = Object.keys(bObj)
|
|
|
|
if (aKeys.length !== bKeys.length) return false
|
|
|
|
return aKeys.every((key) => this.deepEqual(aObj[key], bObj[key]))
|
|
}
|
|
|
|
private extractQueryName(query: string): string {
|
|
// Extract query name from GraphQL query string
|
|
// Examples: "query GetIssueDetails" -> "GetIssueDetails"
|
|
const match = query.match(/(?:query|mutation)\s+(\w+)/)
|
|
return match?.[1] || "UnknownQuery"
|
|
}
|
|
|
|
addResponse(response: MockResponse): void {
|
|
this.mockResponses.push(response)
|
|
}
|
|
|
|
clearResponses(): void {
|
|
this.mockResponses = []
|
|
}
|
|
}
|