diff --git a/package.json b/package.json index 329288a..bae8165 100644 --- a/package.json +++ b/package.json @@ -28,13 +28,7 @@ "type": "git", "url": "git@tangled.org:markbennett.ca/tangled-cli" }, - "keywords": [ - "git", - "tangled", - "pds", - "atproto", - "cli" - ], + "keywords": ["git", "tangled", "pds", "atproto", "cli"], "author": "Mark Bennett", "license": "MIT", "dependencies": { diff --git a/src/lib/issues-api.ts b/src/lib/issues-api.ts index 28d1ef5..d3b8496 100644 --- a/src/lib/issues-api.ts +++ b/src/lib/issues-api.ts @@ -1,7 +1,7 @@ -import type { TangledApiClient } from './api-client.js'; +import type { Record as IssueRecord } from '../lexicon/types/sh/tangled/repo/issue.js'; import { parseAtUri } from '../utils/at-uri.js'; import { requireAuth } from '../utils/auth-helpers.js'; -import type { Record as IssueRecord } from '../lexicon/types/sh/tangled/repo/issue.js'; +import type { TangledApiClient } from './api-client.js'; // Re-export the generated issue record type for convenience export type { IssueRecord }; @@ -10,63 +10,63 @@ export type { IssueRecord }; * Issue record with metadata */ export interface IssueWithMetadata extends IssueRecord { - uri: string; // AT-URI of the issue - cid: string; // Content ID - author: string; // Creator's DID + uri: string; // AT-URI of the issue + cid: string; // Content ID + author: string; // Creator's DID } /** * Parameters for creating an issue */ export interface CreateIssueParams { - client: TangledApiClient; - repoAtUri: string; - title: string; - body?: string; + client: TangledApiClient; + repoAtUri: string; + title: string; + body?: string; } /** * Parameters for listing issues */ export interface ListIssuesParams { - client: TangledApiClient; - repoAtUri: string; - limit?: number; - cursor?: string; + client: TangledApiClient; + repoAtUri: string; + limit?: number; + cursor?: string; } /** * Parameters for getting a specific issue */ export interface GetIssueParams { - client: TangledApiClient; - issueUri: string; + client: TangledApiClient; + issueUri: string; } /** * Parameters for updating an issue */ export interface UpdateIssueParams { - client: TangledApiClient; - issueUri: string; - title?: string; - body?: string; + client: TangledApiClient; + issueUri: string; + title?: string; + body?: string; } /** * Parameters for closing an issue */ export interface CloseIssueParams { - client: TangledApiClient; - issueUri: string; + client: TangledApiClient; + issueUri: string; } /** * Parameters for deleting an issue */ export interface DeleteIssueParams { - client: TangledApiClient; - issueUri: string; + client: TangledApiClient; + issueUri: string; } /** @@ -75,279 +75,271 @@ export interface DeleteIssueParams { * @returns Parsed URI components */ function parseIssueUri(issueUri: string): { - did: string; - collection: string; - rkey: string; + did: string; + collection: string; + rkey: string; } { - const parsed = parseAtUri(issueUri); - if (!parsed || !parsed.rkey) { - throw new Error(`Invalid issue AT-URI: ${issueUri}`); - } - - return { - did: parsed.did, - collection: parsed.collection, - rkey: parsed.rkey, - }; + const parsed = parseAtUri(issueUri); + if (!parsed || !parsed.rkey) { + throw new Error(`Invalid issue AT-URI: ${issueUri}`); + } + + return { + did: parsed.did, + collection: parsed.collection, + rkey: parsed.rkey, + }; } /** * Create a new issue */ -export async function createIssue( - params: CreateIssueParams, -): Promise { - const { client, repoAtUri, title, body } = params; - - // Validate authentication - const session = await requireAuth(client); - - // Build issue record - const record: IssueRecord = { - $type: 'sh.tangled.repo.issue', - repo: repoAtUri, - title, - body, - createdAt: new Date().toISOString(), - }; - - try { - // Create record via AT Protocol - const response = await client.getAgent().com.atproto.repo.createRecord({ - repo: session.did, - collection: 'sh.tangled.repo.issue', - record, - }); - - return { - ...record, - uri: response.data.uri, - cid: response.data.cid, - author: session.did, - }; - } catch (error) { - if (error instanceof Error) { - throw new Error(`Failed to create issue: ${error.message}`); - } - throw new Error('Failed to create issue: Unknown error'); - } +export async function createIssue(params: CreateIssueParams): Promise { + const { client, repoAtUri, title, body } = params; + + // Validate authentication + const session = await requireAuth(client); + + // Build issue record + const record: IssueRecord = { + $type: 'sh.tangled.repo.issue', + repo: repoAtUri, + title, + body, + createdAt: new Date().toISOString(), + }; + + try { + // Create record via AT Protocol + const response = await client.getAgent().com.atproto.repo.createRecord({ + repo: session.did, + collection: 'sh.tangled.repo.issue', + record, + }); + + return { + ...record, + uri: response.data.uri, + cid: response.data.cid, + author: session.did, + }; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to create issue: ${error.message}`); + } + throw new Error('Failed to create issue: Unknown error'); + } } /** * List issues for a repository */ -export async function listIssues( - params: ListIssuesParams, -): Promise<{ - issues: IssueWithMetadata[]; - cursor?: string; +export async function listIssues(params: ListIssuesParams): Promise<{ + issues: IssueWithMetadata[]; + cursor?: string; }> { - const { client, repoAtUri, limit = 50, cursor } = params; - - // Validate authentication - await requireAuth(client); - - // Extract owner DID from repo AT-URI - const parsed = parseAtUri(repoAtUri); - if (!parsed) { - throw new Error(`Invalid repository AT-URI: ${repoAtUri}`); - } - - const ownerDid = parsed.did; - - try { - // List all issue records for the owner - const response = await client.getAgent().com.atproto.repo.listRecords({ - repo: ownerDid, - collection: 'sh.tangled.repo.issue', - limit, - cursor, - }); - - // Filter to only issues for this specific repository - const issues: IssueWithMetadata[] = response.data.records - .filter((record) => { - const issueRecord = record.value as IssueRecord; - return issueRecord.repo === repoAtUri; - }) - .map((record) => ({ - ...(record.value as IssueRecord), - uri: record.uri, - cid: record.cid, - author: ownerDid, - })); - - return { - issues, - cursor: response.data.cursor, - }; - } catch (error) { - if (error instanceof Error) { - throw new Error(`Failed to list issues: ${error.message}`); - } - throw new Error('Failed to list issues: Unknown error'); - } + const { client, repoAtUri, limit = 50, cursor } = params; + + // Validate authentication + await requireAuth(client); + + // Extract owner DID from repo AT-URI + const parsed = parseAtUri(repoAtUri); + if (!parsed) { + throw new Error(`Invalid repository AT-URI: ${repoAtUri}`); + } + + const ownerDid = parsed.did; + + try { + // List all issue records for the owner + const response = await client.getAgent().com.atproto.repo.listRecords({ + repo: ownerDid, + collection: 'sh.tangled.repo.issue', + limit, + cursor, + }); + + // Filter to only issues for this specific repository + const issues: IssueWithMetadata[] = response.data.records + .filter((record) => { + const issueRecord = record.value as IssueRecord; + return issueRecord.repo === repoAtUri; + }) + .map((record) => ({ + ...(record.value as IssueRecord), + uri: record.uri, + cid: record.cid, + author: ownerDid, + })); + + return { + issues, + cursor: response.data.cursor, + }; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to list issues: ${error.message}`); + } + throw new Error('Failed to list issues: Unknown error'); + } } /** * Get a specific issue */ -export async function getIssue( - params: GetIssueParams, -): Promise { - const { client, issueUri } = params; - - // Validate authentication - await requireAuth(client); - - // Parse issue URI - const { did, collection, rkey } = parseIssueUri(issueUri); - - try { - // Get record via AT Protocol - const response = await client.getAgent().com.atproto.repo.getRecord({ - repo: did, - collection, - rkey, - }); - - const record = response.data.value as IssueRecord; - - return { - ...record, - uri: response.data.uri, - cid: response.data.cid as string, // CID is always present in AT Protocol responses - author: did, - }; - } catch (error) { - if (error instanceof Error) { - if (error.message.includes('not found')) { - throw new Error(`Issue not found: ${issueUri}`); - } - throw new Error(`Failed to get issue: ${error.message}`); - } - throw new Error('Failed to get issue: Unknown error'); - } +export async function getIssue(params: GetIssueParams): Promise { + const { client, issueUri } = params; + + // Validate authentication + await requireAuth(client); + + // Parse issue URI + const { did, collection, rkey } = parseIssueUri(issueUri); + + try { + // Get record via AT Protocol + const response = await client.getAgent().com.atproto.repo.getRecord({ + repo: did, + collection, + rkey, + }); + + const record = response.data.value as IssueRecord; + + return { + ...record, + uri: response.data.uri, + cid: response.data.cid as string, // CID is always present in AT Protocol responses + author: did, + }; + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('not found')) { + throw new Error(`Issue not found: ${issueUri}`); + } + throw new Error(`Failed to get issue: ${error.message}`); + } + throw new Error('Failed to get issue: Unknown error'); + } } /** * Update an issue (title and/or body) */ -export async function updateIssue( - params: UpdateIssueParams, -): Promise { - const { client, issueUri, title, body } = params; - - // Validate authentication - const session = await requireAuth(client); - - // Parse issue URI - const { did, collection, rkey } = parseIssueUri(issueUri); - - // Verify user owns the issue - if (did !== session.did) { - throw new Error('Cannot update issue: you are not the author'); - } - - try { - // Get current issue to merge with updates - const currentIssue = await getIssue({ client, issueUri }); - - // Build updated record (merge existing with new values) - const updatedRecord: IssueRecord = { - ...currentIssue, - ...(title !== undefined && { title }), - ...(body !== undefined && { body }), - }; - - // Update record with CID swap for atomic update - const response = await client.getAgent().com.atproto.repo.putRecord({ - repo: did, - collection, - rkey, - record: updatedRecord, - swapRecord: currentIssue.cid, - }); - - return { - ...updatedRecord, - uri: issueUri, - cid: response.data.cid, - author: did, - }; - } catch (error) { - if (error instanceof Error) { - throw new Error(`Failed to update issue: ${error.message}`); - } - throw new Error('Failed to update issue: Unknown error'); - } +export async function updateIssue(params: UpdateIssueParams): Promise { + const { client, issueUri, title, body } = params; + + // Validate authentication + const session = await requireAuth(client); + + // Parse issue URI + const { did, collection, rkey } = parseIssueUri(issueUri); + + // Verify user owns the issue + if (did !== session.did) { + throw new Error('Cannot update issue: you are not the author'); + } + + try { + // Get current issue to merge with updates + const currentIssue = await getIssue({ client, issueUri }); + + // Build updated record (merge existing with new values) + const updatedRecord: IssueRecord = { + ...currentIssue, + ...(title !== undefined && { title }), + ...(body !== undefined && { body }), + }; + + // Update record with CID swap for atomic update + const response = await client.getAgent().com.atproto.repo.putRecord({ + repo: did, + collection, + rkey, + record: updatedRecord, + swapRecord: currentIssue.cid, + }); + + return { + ...updatedRecord, + uri: issueUri, + cid: response.data.cid, + author: did, + }; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to update issue: ${error.message}`); + } + throw new Error('Failed to update issue: Unknown error'); + } } /** * Close an issue by creating/updating a state record */ export async function closeIssue(params: CloseIssueParams): Promise { - const { client, issueUri } = params; - - // Validate authentication - const session = await requireAuth(client); - - try { - // Verify issue exists - await getIssue({ client, issueUri }); - - // Create state record - const stateRecord = { - $type: 'sh.tangled.repo.issue.state', - issue: issueUri, - state: 'sh.tangled.repo.issue.state.closed', - }; - - // Create state record - await client.getAgent().com.atproto.repo.createRecord({ - repo: session.did, - collection: 'sh.tangled.repo.issue.state', - record: stateRecord, - }); - } catch (error) { - if (error instanceof Error) { - throw new Error(`Failed to close issue: ${error.message}`); - } - throw new Error('Failed to close issue: Unknown error'); - } + const { client, issueUri } = params; + + // Validate authentication + const session = await requireAuth(client); + + try { + // Verify issue exists + await getIssue({ client, issueUri }); + + // Create state record + const stateRecord = { + $type: 'sh.tangled.repo.issue.state', + issue: issueUri, + state: 'sh.tangled.repo.issue.state.closed', + }; + + // Create state record + await client.getAgent().com.atproto.repo.createRecord({ + repo: session.did, + collection: 'sh.tangled.repo.issue.state', + record: stateRecord, + }); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to close issue: ${error.message}`); + } + throw new Error('Failed to close issue: Unknown error'); + } } /** * Delete an issue */ export async function deleteIssue(params: DeleteIssueParams): Promise { - const { client, issueUri } = params; - - // Validate authentication - const session = await requireAuth(client); - - // Parse issue URI - const { did, collection, rkey } = parseIssueUri(issueUri); - - // Verify user owns the issue - if (did !== session.did) { - throw new Error('Cannot delete issue: you are not the author'); - } - - try { - // Delete record via AT Protocol - await client.getAgent().com.atproto.repo.deleteRecord({ - repo: did, - collection, - rkey, - }); - } catch (error) { - if (error instanceof Error) { - if (error.message.includes('not found')) { - throw new Error(`Issue not found: ${issueUri}`); - } - throw new Error(`Failed to delete issue: ${error.message}`); - } - throw new Error('Failed to delete issue: Unknown error'); - } + const { client, issueUri } = params; + + // Validate authentication + const session = await requireAuth(client); + + // Parse issue URI + const { did, collection, rkey } = parseIssueUri(issueUri); + + // Verify user owns the issue + if (did !== session.did) { + throw new Error('Cannot delete issue: you are not the author'); + } + + try { + // Delete record via AT Protocol + await client.getAgent().com.atproto.repo.deleteRecord({ + repo: did, + collection, + rkey, + }); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('not found')) { + throw new Error(`Issue not found: ${issueUri}`); + } + throw new Error(`Failed to delete issue: ${error.message}`); + } + throw new Error('Failed to delete issue: Unknown error'); + } } diff --git a/src/utils/at-uri.ts b/src/utils/at-uri.ts index c14ccda..66f0d5b 100644 --- a/src/utils/at-uri.ts +++ b/src/utils/at-uri.ts @@ -6,23 +6,25 @@ import type { TangledApiClient } from '../lib/api-client.js'; * @returns Parsed components or null if invalid */ export function parseAtUri(uri: string): { - did: string; - collection: string; - rkey?: string; + did: string; + collection: string; + rkey?: string; } | null { - // AT-URI format: at://did:method:identifier/collection[/rkey] - const match = uri.match(/^at:\/\/(did:[a-z]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*)(?:\/([a-zA-Z0-9._-]+))?$/); + // AT-URI format: at://did:method:identifier/collection[/rkey] + const match = uri.match( + /^at:\/\/(did:[a-z]+:[a-zA-Z0-9._:%-]+)\/([a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*)(?:\/([a-zA-Z0-9._-]+))?$/ + ); - if (!match) { - return null; - } + if (!match) { + return null; + } - const [, did, collection, rkey] = match; - return { - did, - collection, - ...(rkey && { rkey }), - }; + const [, did, collection, rkey] = match; + return { + did, + collection, + ...(rkey && { rkey }), + }; } /** @@ -33,30 +35,28 @@ export function parseAtUri(uri: string): { * @throws Error if handle cannot be resolved */ export async function resolveHandleToDid( - handle: string, - client: TangledApiClient, + handle: string, + client: TangledApiClient ): Promise { - // Strip leading @ if present - const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; + // Strip leading @ if present + const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; - try { - const response = await client.getAgent().com.atproto.identity.resolveHandle({ - handle: cleanHandle, - }); + try { + const response = await client.getAgent().com.atproto.identity.resolveHandle({ + handle: cleanHandle, + }); - if (!response.data.did) { - throw new Error(`No DID found for handle: ${cleanHandle}`); - } + if (!response.data.did) { + throw new Error(`No DID found for handle: ${cleanHandle}`); + } - return response.data.did; - } catch (error) { - if (error instanceof Error) { - throw new Error( - `Failed to resolve handle '${cleanHandle}': ${error.message}`, - ); - } - throw new Error(`Failed to resolve handle '${cleanHandle}': Unknown error`); - } + return response.data.did; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to resolve handle '${cleanHandle}': ${error.message}`); + } + throw new Error(`Failed to resolve handle '${cleanHandle}': Unknown error`); + } } /** @@ -67,22 +67,22 @@ export async function resolveHandleToDid( * @returns AT-URI string (e.g., "at://did:plc:abc/sh.tangled.repo/repoName") */ export async function buildRepoAtUri( - ownerDidOrHandle: string, - repoName: string, - client: TangledApiClient, + ownerDidOrHandle: string, + repoName: string, + client: TangledApiClient ): Promise { - // Check if owner is already a DID - const isDid = ownerDidOrHandle.startsWith('did:'); + // Check if owner is already a DID + const isDid = ownerDidOrHandle.startsWith('did:'); - let did: string; - if (isDid) { - did = ownerDidOrHandle; - } else { - // Resolve handle to DID - did = await resolveHandleToDid(ownerDidOrHandle, client); - } + let did: string; + if (isDid) { + did = ownerDidOrHandle; + } else { + // Resolve handle to DID + did = await resolveHandleToDid(ownerDidOrHandle, client); + } - // Construct AT-URI for repository - // Format: at://{did}/sh.tangled.repo/{repoName} - return `at://${did}/sh.tangled.repo/${repoName}`; + // Construct AT-URI for repository + // Format: at://{did}/sh.tangled.repo/{repoName} + return `at://${did}/sh.tangled.repo/${repoName}`; } diff --git a/src/utils/auth-helpers.ts b/src/utils/auth-helpers.ts index 93d65f0..2d548c7 100644 --- a/src/utils/auth-helpers.ts +++ b/src/utils/auth-helpers.ts @@ -6,17 +6,17 @@ import type { TangledApiClient } from '../lib/api-client.js'; * @returns The current session with did and handle */ export async function requireAuth(client: TangledApiClient): Promise<{ - did: string; - handle: string; + did: string; + handle: string; }> { - if (!(await client.isAuthenticated())) { - throw new Error('Must be authenticated. Run "tangled auth login" first.'); - } + if (!(await client.isAuthenticated())) { + throw new Error('Must be authenticated. Run "tangled auth login" first.'); + } - const session = client.getSession(); - if (!session) { - throw new Error('No active session found'); - } + const session = client.getSession(); + if (!session) { + throw new Error('No active session found'); + } - return session; + return session; } diff --git a/src/utils/body-input.ts b/src/utils/body-input.ts index aa12c28..e4448f3 100644 --- a/src/utils/body-input.ts +++ b/src/utils/body-input.ts @@ -11,66 +11,62 @@ import * as process from 'node:process'; * @throws Error if file doesn't exist or cannot be read */ export async function readBodyInput( - bodyString?: string, - bodyFilePath?: string, + bodyString?: string, + bodyFilePath?: string ): Promise { - // Error if both are provided - if (bodyString !== undefined && bodyFilePath !== undefined) { - throw new Error( - 'Cannot specify both --body and --body-file. Choose one input method.', - ); - } - - // Direct string input (including empty string) - if (bodyString !== undefined) { - return bodyString; - } - - // File or stdin input - if (bodyFilePath) { - // Read from stdin - if (bodyFilePath === '-') { - return await readFromStdin(); - } - - // Read from file - try { - const stats = await fs.stat(bodyFilePath); - - if (stats.isDirectory()) { - throw new Error(`'${bodyFilePath}' is a directory, not a file`); - } - - const content = await fs.readFile(bodyFilePath, 'utf-8'); - return content; - } catch (error) { - if (error instanceof Error) { - // Re-throw our custom directory error - if (error.message.includes('is a directory')) { - throw error; - } - - // Handle ENOENT (file not found) - if ('code' in error && error.code === 'ENOENT') { - throw new Error(`File not found: ${bodyFilePath}`); - } - - // Handle EACCES (permission denied) - if ('code' in error && error.code === 'EACCES') { - throw new Error(`Permission denied: ${bodyFilePath}`); - } - - throw new Error( - `Failed to read file '${bodyFilePath}': ${error.message}`, - ); - } - - throw new Error(`Failed to read file '${bodyFilePath}': Unknown error`); - } - } - - // No input provided - return undefined; + // Error if both are provided + if (bodyString !== undefined && bodyFilePath !== undefined) { + throw new Error('Cannot specify both --body and --body-file. Choose one input method.'); + } + + // Direct string input (including empty string) + if (bodyString !== undefined) { + return bodyString; + } + + // File or stdin input + if (bodyFilePath) { + // Read from stdin + if (bodyFilePath === '-') { + return await readFromStdin(); + } + + // Read from file + try { + const stats = await fs.stat(bodyFilePath); + + if (stats.isDirectory()) { + throw new Error(`'${bodyFilePath}' is a directory, not a file`); + } + + const content = await fs.readFile(bodyFilePath, 'utf-8'); + return content; + } catch (error) { + if (error instanceof Error) { + // Re-throw our custom directory error + if (error.message.includes('is a directory')) { + throw error; + } + + // Handle ENOENT (file not found) + if ('code' in error && error.code === 'ENOENT') { + throw new Error(`File not found: ${bodyFilePath}`); + } + + // Handle EACCES (permission denied) + if ('code' in error && error.code === 'EACCES') { + throw new Error(`Permission denied: ${bodyFilePath}`); + } + + throw new Error(`Failed to read file '${bodyFilePath}': ${error.message}`); + } + + throw new Error(`Failed to read file '${bodyFilePath}': Unknown error`); + } + } + + // No input provided + return undefined; } /** @@ -78,23 +74,23 @@ export async function readBodyInput( * @returns Content from stdin as string */ async function readFromStdin(): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; - process.stdin.on('data', (chunk: Buffer) => { - chunks.push(chunk); - }); + process.stdin.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); - process.stdin.on('end', () => { - const content = Buffer.concat(chunks).toString('utf-8'); - resolve(content); - }); + process.stdin.on('end', () => { + const content = Buffer.concat(chunks).toString('utf-8'); + resolve(content); + }); - process.stdin.on('error', (error: Error) => { - reject(new Error(`Failed to read from stdin: ${error.message}`)); - }); + process.stdin.on('error', (error: Error) => { + reject(new Error(`Failed to read from stdin: ${error.message}`)); + }); - // Resume stdin in case it's paused - process.stdin.resume(); - }); + // Resume stdin in case it's paused + process.stdin.resume(); + }); } diff --git a/src/utils/validation.ts b/src/utils/validation.ts index bc3fa2e..b0b9ecb 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -136,36 +136,36 @@ export function isValidTangledDid(did: string): boolean { * Titles must be 1-256 characters */ export const issueTitleSchema = z - .string() - .min(1, 'Issue title cannot be empty') - .max(256, 'Issue title must be 256 characters or less'); + .string() + .min(1, 'Issue title cannot be empty') + .max(256, 'Issue title must be 256 characters or less'); /** * Validation schema for issue body * Body is optional but limited to 50,000 characters */ export const issueBodySchema = z - .string() - .max(50000, 'Issue body must be 50,000 characters or less') - .optional(); + .string() + .max(50000, 'Issue body must be 50,000 characters or less') + .optional(); /** * Validation schema for AT-URI * Format: at://did:method:identifier/collection[/rkey] */ export const atUriSchema = z - .string() - .regex( - /^at:\/\/did:[a-z]+:[a-zA-Z0-9._:%-]+\/[a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*(?:\/[a-zA-Z0-9._-]+)?$/, - 'Invalid AT-URI format. Expected: at://did:method:id/collection[/rkey]', - ); + .string() + .regex( + /^at:\/\/did:[a-z]+:[a-zA-Z0-9._:%-]+\/[a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*(?:\/[a-zA-Z0-9._-]+)?$/, + 'Invalid AT-URI format. Expected: at://did:method:id/collection[/rkey]' + ); /** * Validate an issue title * @throws {z.ZodError} if validation fails */ export function validateIssueTitle(title: string): string { - return issueTitleSchema.parse(title); + return issueTitleSchema.parse(title); } /** @@ -173,7 +173,7 @@ export function validateIssueTitle(title: string): string { * @throws {z.ZodError} if validation fails */ export function validateIssueBody(body: string): string { - return issueBodySchema.parse(body) ?? ''; + return issueBodySchema.parse(body) ?? ''; } /** @@ -181,5 +181,5 @@ export function validateIssueBody(body: string): string { * Returns true/false without throwing */ export function isValidAtUri(uri: string): boolean { - return atUriSchema.safeParse(uri).success; + return atUriSchema.safeParse(uri).success; } diff --git a/tests/lib/issues-api.test.ts b/tests/lib/issues-api.test.ts index c011737..c308c02 100644 --- a/tests/lib/issues-api.test.ts +++ b/tests/lib/issues-api.test.ts @@ -1,671 +1,663 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TangledApiClient } from '../../src/lib/api-client.js'; import { - closeIssue, - createIssue, - deleteIssue, - getIssue, - listIssues, - updateIssue, + closeIssue, + createIssue, + deleteIssue, + getIssue, + listIssues, + updateIssue, } from '../../src/lib/issues-api.js'; -import type { TangledApiClient } from '../../src/lib/api-client.js'; // Mock API client factory const createMockClient = (authenticated = true): TangledApiClient => { - const mockAgent = { - com: { - atproto: { - repo: { - createRecord: vi.fn(), - listRecords: vi.fn(), - getRecord: vi.fn(), - putRecord: vi.fn(), - deleteRecord: vi.fn(), - }, - }, - }, - }; - - return { - isAuthenticated: vi.fn(async () => authenticated), - getSession: vi.fn(() => - authenticated - ? { did: 'did:plc:test123', handle: 'test.bsky.social' } - : null, - ), - getAgent: vi.fn(() => mockAgent), - } as unknown as TangledApiClient; + const mockAgent = { + com: { + atproto: { + repo: { + createRecord: vi.fn(), + listRecords: vi.fn(), + getRecord: vi.fn(), + putRecord: vi.fn(), + deleteRecord: vi.fn(), + }, + }, + }, + }; + + return { + isAuthenticated: vi.fn(async () => authenticated), + getSession: vi.fn(() => + authenticated ? { did: 'did:plc:test123', handle: 'test.bsky.social' } : null + ), + getAgent: vi.fn(() => mockAgent), + } as unknown as TangledApiClient; }; describe('createIssue', () => { - let mockClient: TangledApiClient; - - beforeEach(() => { - mockClient = createMockClient(true); - }); - - it('should create an issue with all fields', async () => { - const mockCreateRecord = vi.fn().mockResolvedValue({ - data: { - uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', - cid: 'cid123', - }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - createRecord: mockCreateRecord, - }, - }, - }, - } as never); - - const result = await createIssue({ - client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Bug: Login fails', - body: 'Detailed description of the bug', - }); - - expect(result).toMatchObject({ - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Bug: Login fails', - body: 'Detailed description of the bug', - uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', - cid: 'cid123', - author: 'did:plc:test123', - }); - - expect(mockCreateRecord).toHaveBeenCalledWith({ - repo: 'did:plc:test123', - collection: 'sh.tangled.repo.issue', - record: expect.objectContaining({ - $type: 'sh.tangled.repo.issue', - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Bug: Login fails', - body: 'Detailed description of the bug', - createdAt: expect.any(String), - }), - }); - }); - - it('should create an issue without body', async () => { - const mockCreateRecord = vi.fn().mockResolvedValue({ - data: { - uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', - cid: 'cid123', - }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - createRecord: mockCreateRecord, - }, - }, - }, - } as never); - - const result = await createIssue({ - client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Simple issue', - }); - - expect(result.body).toBeUndefined(); - expect(mockCreateRecord).toHaveBeenCalled(); - }); - - it('should throw error when not authenticated', async () => { - mockClient = createMockClient(false); - - await expect( - createIssue({ - client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Test', - }), - ).rejects.toThrow('Must be authenticated'); - }); - - it('should throw error on API failure', async () => { - const mockCreateRecord = vi - .fn() - .mockRejectedValue(new Error('API error')); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - createRecord: mockCreateRecord, - }, - }, - }, - } as never); - - await expect( - createIssue({ - client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Test', - }), - ).rejects.toThrow('Failed to create issue: API error'); - }); + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + }); + + it('should create an issue with all fields', async () => { + const mockCreateRecord = vi.fn().mockResolvedValue({ + data: { + uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', + cid: 'cid123', + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + createRecord: mockCreateRecord, + }, + }, + }, + } as never); + + const result = await createIssue({ + client: mockClient, + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Bug: Login fails', + body: 'Detailed description of the bug', + }); + + expect(result).toMatchObject({ + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Bug: Login fails', + body: 'Detailed description of the bug', + uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', + cid: 'cid123', + author: 'did:plc:test123', + }); + + expect(mockCreateRecord).toHaveBeenCalledWith({ + repo: 'did:plc:test123', + collection: 'sh.tangled.repo.issue', + record: expect.objectContaining({ + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Bug: Login fails', + body: 'Detailed description of the bug', + createdAt: expect.any(String), + }), + }); + }); + + it('should create an issue without body', async () => { + const mockCreateRecord = vi.fn().mockResolvedValue({ + data: { + uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', + cid: 'cid123', + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + createRecord: mockCreateRecord, + }, + }, + }, + } as never); + + const result = await createIssue({ + client: mockClient, + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Simple issue', + }); + + expect(result.body).toBeUndefined(); + expect(mockCreateRecord).toHaveBeenCalled(); + }); + + it('should throw error when not authenticated', async () => { + mockClient = createMockClient(false); + + await expect( + createIssue({ + client: mockClient, + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Test', + }) + ).rejects.toThrow('Must be authenticated'); + }); + + it('should throw error on API failure', async () => { + const mockCreateRecord = vi.fn().mockRejectedValue(new Error('API error')); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + createRecord: mockCreateRecord, + }, + }, + }, + } as never); + + await expect( + createIssue({ + client: mockClient, + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Test', + }) + ).rejects.toThrow('Failed to create issue: API error'); + }); }); describe('listIssues', () => { - let mockClient: TangledApiClient; - - beforeEach(() => { - mockClient = createMockClient(true); - }); - - it('should list issues for a repository', async () => { - const mockListRecords = vi.fn().mockResolvedValue({ - data: { - records: [ - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - cid: 'cid1', - value: { - $type: 'sh.tangled.repo.issue', - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Issue 1', - body: 'Description 1', - createdAt: '2024-01-01T00:00:00.000Z', - }, - }, - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', - cid: 'cid2', - value: { - $type: 'sh.tangled.repo.issue', - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Issue 2', - createdAt: '2024-01-02T00:00:00.000Z', - }, - }, - ], - cursor: undefined, - }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - listRecords: mockListRecords, - }, - }, - }, - } as never); - - const result = await listIssues({ - client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', - }); - - expect(result.issues).toHaveLength(2); - expect(result.issues[0]).toMatchObject({ - title: 'Issue 1', - body: 'Description 1', - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - }); - }); - - it('should filter issues by repository', async () => { - const mockListRecords = vi.fn().mockResolvedValue({ - data: { - records: [ - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - cid: 'cid1', - value: { - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Issue 1', - createdAt: '2024-01-01T00:00:00.000Z', - }, - }, - { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', - cid: 'cid2', - value: { - repo: 'at://did:plc:owner/sh.tangled.repo/other-repo', - title: 'Issue 2', - createdAt: '2024-01-02T00:00:00.000Z', - }, - }, - ], - cursor: undefined, - }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - listRecords: mockListRecords, - }, - }, - }, - } as never); - - const result = await listIssues({ - client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', - }); - - // Should only include issue from my-repo, not other-repo - expect(result.issues).toHaveLength(1); - expect(result.issues[0].title).toBe('Issue 1'); - }); - - it('should return empty array when no issues found', async () => { - const mockListRecords = vi.fn().mockResolvedValue({ - data: { - records: [], - cursor: undefined, - }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - listRecords: mockListRecords, - }, - }, - }, - } as never); - - const result = await listIssues({ - client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', - }); - - expect(result.issues).toEqual([]); - }); - - it('should throw error when not authenticated', async () => { - mockClient = createMockClient(false); - - await expect( - listIssues({ - client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', - }), - ).rejects.toThrow('Must be authenticated'); - }); - - it('should throw error for invalid repo URI', async () => { - await expect( - listIssues({ - client: mockClient, - repoAtUri: 'invalid-uri', - }), - ).rejects.toThrow('Invalid repository AT-URI'); - }); + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + }); + + it('should list issues for a repository', async () => { + const mockListRecords = vi.fn().mockResolvedValue({ + data: { + records: [ + { + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + cid: 'cid1', + value: { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Issue 1', + body: 'Description 1', + createdAt: '2024-01-01T00:00:00.000Z', + }, + }, + { + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', + cid: 'cid2', + value: { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Issue 2', + createdAt: '2024-01-02T00:00:00.000Z', + }, + }, + ], + cursor: undefined, + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + listRecords: mockListRecords, + }, + }, + }, + } as never); + + const result = await listIssues({ + client: mockClient, + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + }); + + expect(result.issues).toHaveLength(2); + expect(result.issues[0]).toMatchObject({ + title: 'Issue 1', + body: 'Description 1', + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + }); + }); + + it('should filter issues by repository', async () => { + const mockListRecords = vi.fn().mockResolvedValue({ + data: { + records: [ + { + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + cid: 'cid1', + value: { + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Issue 1', + createdAt: '2024-01-01T00:00:00.000Z', + }, + }, + { + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', + cid: 'cid2', + value: { + repo: 'at://did:plc:owner/sh.tangled.repo/other-repo', + title: 'Issue 2', + createdAt: '2024-01-02T00:00:00.000Z', + }, + }, + ], + cursor: undefined, + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + listRecords: mockListRecords, + }, + }, + }, + } as never); + + const result = await listIssues({ + client: mockClient, + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + }); + + // Should only include issue from my-repo, not other-repo + expect(result.issues).toHaveLength(1); + expect(result.issues[0].title).toBe('Issue 1'); + }); + + it('should return empty array when no issues found', async () => { + const mockListRecords = vi.fn().mockResolvedValue({ + data: { + records: [], + cursor: undefined, + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + listRecords: mockListRecords, + }, + }, + }, + } as never); + + const result = await listIssues({ + client: mockClient, + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + }); + + expect(result.issues).toEqual([]); + }); + + it('should throw error when not authenticated', async () => { + mockClient = createMockClient(false); + + await expect( + listIssues({ + client: mockClient, + repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + }) + ).rejects.toThrow('Must be authenticated'); + }); + + it('should throw error for invalid repo URI', async () => { + await expect( + listIssues({ + client: mockClient, + repoAtUri: 'invalid-uri', + }) + ).rejects.toThrow('Invalid repository AT-URI'); + }); }); describe('getIssue', () => { - let mockClient: TangledApiClient; - - beforeEach(() => { - mockClient = createMockClient(true); - }); - - it('should get a specific issue', async () => { - const mockGetRecord = vi.fn().mockResolvedValue({ - data: { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - cid: 'cid1', - value: { - $type: 'sh.tangled.repo.issue', - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Test Issue', - body: 'Test Description', - createdAt: '2024-01-01T00:00:00.000Z', - }, - }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - getRecord: mockGetRecord, - }, - }, - }, - } as never); - - const result = await getIssue({ - client: mockClient, - issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - }); - - expect(result).toMatchObject({ - title: 'Test Issue', - body: 'Test Description', - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - cid: 'cid1', - }); - - expect(mockGetRecord).toHaveBeenCalledWith({ - repo: 'did:plc:owner', - collection: 'sh.tangled.repo.issue', - rkey: 'issue1', - }); - }); - - it('should throw error when issue not found', async () => { - const mockGetRecord = vi - .fn() - .mockRejectedValue(new Error('Record not found')); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - getRecord: mockGetRecord, - }, - }, - }, - } as never); - - await expect( - getIssue({ - client: mockClient, - issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', - }), - ).rejects.toThrow('Issue not found'); - }); - - it('should throw error for invalid issue URI', async () => { - await expect( - getIssue({ - client: mockClient, - issueUri: 'invalid-uri', - }), - ).rejects.toThrow('Invalid issue AT-URI'); - }); - - it('should throw error when not authenticated', async () => { - mockClient = createMockClient(false); - - await expect( - getIssue({ - client: mockClient, - issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - }), - ).rejects.toThrow('Must be authenticated'); - }); + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + }); + + it('should get a specific issue', async () => { + const mockGetRecord = vi.fn().mockResolvedValue({ + data: { + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + cid: 'cid1', + value: { + $type: 'sh.tangled.repo.issue', + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Test Issue', + body: 'Test Description', + createdAt: '2024-01-01T00:00:00.000Z', + }, + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + getRecord: mockGetRecord, + }, + }, + }, + } as never); + + const result = await getIssue({ + client: mockClient, + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + }); + + expect(result).toMatchObject({ + title: 'Test Issue', + body: 'Test Description', + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + cid: 'cid1', + }); + + expect(mockGetRecord).toHaveBeenCalledWith({ + repo: 'did:plc:owner', + collection: 'sh.tangled.repo.issue', + rkey: 'issue1', + }); + }); + + it('should throw error when issue not found', async () => { + const mockGetRecord = vi.fn().mockRejectedValue(new Error('Record not found')); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + getRecord: mockGetRecord, + }, + }, + }, + } as never); + + await expect( + getIssue({ + client: mockClient, + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', + }) + ).rejects.toThrow('Issue not found'); + }); + + it('should throw error for invalid issue URI', async () => { + await expect( + getIssue({ + client: mockClient, + issueUri: 'invalid-uri', + }) + ).rejects.toThrow('Invalid issue AT-URI'); + }); + + it('should throw error when not authenticated', async () => { + mockClient = createMockClient(false); + + await expect( + getIssue({ + client: mockClient, + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + }) + ).rejects.toThrow('Must be authenticated'); + }); }); describe('updateIssue', () => { - let mockClient: TangledApiClient; - - beforeEach(() => { - mockClient = createMockClient(true); - }); - - it('should update issue title', async () => { - const mockGetRecord = vi.fn().mockResolvedValue({ - data: { - uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', - cid: 'old-cid', - value: { - repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', - title: 'Old Title', - body: 'Original body', - createdAt: '2024-01-01T00:00:00.000Z', - }, - }, - }); - - const mockPutRecord = vi.fn().mockResolvedValue({ - data: { - uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', - cid: 'new-cid', - }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - getRecord: mockGetRecord, - putRecord: mockPutRecord, - }, - }, - }, - } as never); - - const result = await updateIssue({ - client: mockClient, - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', - title: 'New Title', - }); - - expect(result.title).toBe('New Title'); - expect(result.body).toBe('Original body'); // Body unchanged - - expect(mockPutRecord).toHaveBeenCalledWith({ - repo: 'did:plc:test123', - collection: 'sh.tangled.repo.issue', - rkey: 'issue1', - record: expect.objectContaining({ - title: 'New Title', - body: 'Original body', - }), - swapRecord: 'old-cid', - }); - }); - - it('should update issue body', async () => { - const mockGetRecord = vi.fn().mockResolvedValue({ - data: { - uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', - cid: 'old-cid', - value: { - repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', - title: 'Title', - body: 'Old body', - createdAt: '2024-01-01T00:00:00.000Z', - }, - }, - }); - - const mockPutRecord = vi.fn().mockResolvedValue({ - data: { - cid: 'new-cid', - }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - getRecord: mockGetRecord, - putRecord: mockPutRecord, - }, - }, - }, - } as never); - - const result = await updateIssue({ - client: mockClient, - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', - body: 'New body', - }); - - expect(result.title).toBe('Title'); // Title unchanged - expect(result.body).toBe('New body'); - }); - - it('should throw error when updating issue not owned by user', async () => { - await expect( - updateIssue({ - client: mockClient, - issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', - title: 'New Title', - }), - ).rejects.toThrow('Cannot update issue: you are not the author'); - }); - - it('should throw error when not authenticated', async () => { - mockClient = createMockClient(false); - - await expect( - updateIssue({ - client: mockClient, - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', - title: 'New Title', - }), - ).rejects.toThrow('Must be authenticated'); - }); + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + }); + + it('should update issue title', async () => { + const mockGetRecord = vi.fn().mockResolvedValue({ + data: { + uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', + cid: 'old-cid', + value: { + repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', + title: 'Old Title', + body: 'Original body', + createdAt: '2024-01-01T00:00:00.000Z', + }, + }, + }); + + const mockPutRecord = vi.fn().mockResolvedValue({ + data: { + uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', + cid: 'new-cid', + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + getRecord: mockGetRecord, + putRecord: mockPutRecord, + }, + }, + }, + } as never); + + const result = await updateIssue({ + client: mockClient, + issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', + title: 'New Title', + }); + + expect(result.title).toBe('New Title'); + expect(result.body).toBe('Original body'); // Body unchanged + + expect(mockPutRecord).toHaveBeenCalledWith({ + repo: 'did:plc:test123', + collection: 'sh.tangled.repo.issue', + rkey: 'issue1', + record: expect.objectContaining({ + title: 'New Title', + body: 'Original body', + }), + swapRecord: 'old-cid', + }); + }); + + it('should update issue body', async () => { + const mockGetRecord = vi.fn().mockResolvedValue({ + data: { + uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', + cid: 'old-cid', + value: { + repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', + title: 'Title', + body: 'Old body', + createdAt: '2024-01-01T00:00:00.000Z', + }, + }, + }); + + const mockPutRecord = vi.fn().mockResolvedValue({ + data: { + cid: 'new-cid', + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + getRecord: mockGetRecord, + putRecord: mockPutRecord, + }, + }, + }, + } as never); + + const result = await updateIssue({ + client: mockClient, + issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', + body: 'New body', + }); + + expect(result.title).toBe('Title'); // Title unchanged + expect(result.body).toBe('New body'); + }); + + it('should throw error when updating issue not owned by user', async () => { + await expect( + updateIssue({ + client: mockClient, + issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', + title: 'New Title', + }) + ).rejects.toThrow('Cannot update issue: you are not the author'); + }); + + it('should throw error when not authenticated', async () => { + mockClient = createMockClient(false); + + await expect( + updateIssue({ + client: mockClient, + issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', + title: 'New Title', + }) + ).rejects.toThrow('Must be authenticated'); + }); }); describe('closeIssue', () => { - let mockClient: TangledApiClient; - - beforeEach(() => { - mockClient = createMockClient(true); - }); - - it('should close an issue', async () => { - const mockGetRecord = vi.fn().mockResolvedValue({ - data: { - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - cid: 'cid1', - value: { - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', - title: 'Test Issue', - createdAt: '2024-01-01T00:00:00.000Z', - }, - }, - }); - - const mockCreateRecord = vi.fn().mockResolvedValue({ - data: { - uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1', - cid: 'state-cid', - }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - getRecord: mockGetRecord, - createRecord: mockCreateRecord, - }, - }, - }, - } as never); - - await closeIssue({ - client: mockClient, - issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - }); - - expect(mockCreateRecord).toHaveBeenCalledWith({ - repo: 'did:plc:test123', - collection: 'sh.tangled.repo.issue.state', - record: { - $type: 'sh.tangled.repo.issue.state', - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - state: 'sh.tangled.repo.issue.state.closed', - }, - }); - }); - - it('should throw error when not authenticated', async () => { - mockClient = createMockClient(false); - - await expect( - closeIssue({ - client: mockClient, - issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', - }), - ).rejects.toThrow('Must be authenticated'); - }); + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + }); + + it('should close an issue', async () => { + const mockGetRecord = vi.fn().mockResolvedValue({ + data: { + uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + cid: 'cid1', + value: { + repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', + title: 'Test Issue', + createdAt: '2024-01-01T00:00:00.000Z', + }, + }, + }); + + const mockCreateRecord = vi.fn().mockResolvedValue({ + data: { + uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1', + cid: 'state-cid', + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + getRecord: mockGetRecord, + createRecord: mockCreateRecord, + }, + }, + }, + } as never); + + await closeIssue({ + client: mockClient, + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + }); + + expect(mockCreateRecord).toHaveBeenCalledWith({ + repo: 'did:plc:test123', + collection: 'sh.tangled.repo.issue.state', + record: { + $type: 'sh.tangled.repo.issue.state', + issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + state: 'sh.tangled.repo.issue.state.closed', + }, + }); + }); + + it('should throw error when not authenticated', async () => { + mockClient = createMockClient(false); + + await expect( + closeIssue({ + client: mockClient, + issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', + }) + ).rejects.toThrow('Must be authenticated'); + }); }); describe('deleteIssue', () => { - let mockClient: TangledApiClient; - - beforeEach(() => { - mockClient = createMockClient(true); - }); - - it('should delete an issue', async () => { - const mockDeleteRecord = vi.fn().mockResolvedValue({}); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - deleteRecord: mockDeleteRecord, - }, - }, - }, - } as never); - - await deleteIssue({ - client: mockClient, - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', - }); - - expect(mockDeleteRecord).toHaveBeenCalledWith({ - repo: 'did:plc:test123', - collection: 'sh.tangled.repo.issue', - rkey: 'issue1', - }); - }); - - it('should throw error when deleting issue not owned by user', async () => { - await expect( - deleteIssue({ - client: mockClient, - issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', - }), - ).rejects.toThrow('Cannot delete issue: you are not the author'); - }); - - it('should throw error when issue not found', async () => { - const mockDeleteRecord = vi - .fn() - .mockRejectedValue(new Error('Record not found')); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - repo: { - deleteRecord: mockDeleteRecord, - }, - }, - }, - } as never); - - await expect( - deleteIssue({ - client: mockClient, - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/nonexistent', - }), - ).rejects.toThrow('Issue not found'); - }); - - it('should throw error when not authenticated', async () => { - mockClient = createMockClient(false); - - await expect( - deleteIssue({ - client: mockClient, - issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', - }), - ).rejects.toThrow('Must be authenticated'); - }); + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + }); + + it('should delete an issue', async () => { + const mockDeleteRecord = vi.fn().mockResolvedValue({}); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + deleteRecord: mockDeleteRecord, + }, + }, + }, + } as never); + + await deleteIssue({ + client: mockClient, + issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', + }); + + expect(mockDeleteRecord).toHaveBeenCalledWith({ + repo: 'did:plc:test123', + collection: 'sh.tangled.repo.issue', + rkey: 'issue1', + }); + }); + + it('should throw error when deleting issue not owned by user', async () => { + await expect( + deleteIssue({ + client: mockClient, + issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', + }) + ).rejects.toThrow('Cannot delete issue: you are not the author'); + }); + + it('should throw error when issue not found', async () => { + const mockDeleteRecord = vi.fn().mockRejectedValue(new Error('Record not found')); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + deleteRecord: mockDeleteRecord, + }, + }, + }, + } as never); + + await expect( + deleteIssue({ + client: mockClient, + issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/nonexistent', + }) + ).rejects.toThrow('Issue not found'); + }); + + it('should throw error when not authenticated', async () => { + mockClient = createMockClient(false); + + await expect( + deleteIssue({ + client: mockClient, + issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', + }) + ).rejects.toThrow('Must be authenticated'); + }); }); diff --git a/tests/utils/at-uri.test.ts b/tests/utils/at-uri.test.ts index 34f73db..ed5ff91 100644 --- a/tests/utils/at-uri.test.ts +++ b/tests/utils/at-uri.test.ts @@ -1,240 +1,216 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - buildRepoAtUri, - parseAtUri, - resolveHandleToDid, -} from '../../src/utils/at-uri.js'; import type { TangledApiClient } from '../../src/lib/api-client.js'; +import { buildRepoAtUri, parseAtUri, resolveHandleToDid } from '../../src/utils/at-uri.js'; // Mock API client const createMockClient = (): TangledApiClient => { - return { - getAgent: vi.fn(() => ({ - com: { - atproto: { - identity: { - resolveHandle: vi.fn(), - }, - }, - }, - })), - } as unknown as TangledApiClient; + return { + getAgent: vi.fn(() => ({ + com: { + atproto: { + identity: { + resolveHandle: vi.fn(), + }, + }, + }, + })), + } as unknown as TangledApiClient; }; describe('parseAtUri', () => { - it('should parse AT-URI with rkey', () => { - const uri = 'at://did:plc:abc123/sh.tangled.repo.issue/xyz789'; - const result = parseAtUri(uri); - - expect(result).toEqual({ - did: 'did:plc:abc123', - collection: 'sh.tangled.repo.issue', - rkey: 'xyz789', - }); - }); - - it('should parse AT-URI without rkey', () => { - const uri = 'at://did:plc:abc123/sh.tangled.repo'; - const result = parseAtUri(uri); - - expect(result).toEqual({ - did: 'did:plc:abc123', - collection: 'sh.tangled.repo', - }); - }); - - it('should parse AT-URI with nested collection', () => { - const uri = 'at://did:plc:abc123/sh.tangled.repo.issue.state/xyz'; - const result = parseAtUri(uri); - - expect(result).toEqual({ - did: 'did:plc:abc123', - collection: 'sh.tangled.repo.issue.state', - rkey: 'xyz', - }); - }); - - it('should return null for invalid URI', () => { - expect(parseAtUri('not-a-uri')).toBeNull(); - expect(parseAtUri('http://example.com')).toBeNull(); - expect(parseAtUri('at://invalid-did/collection')).toBeNull(); - expect(parseAtUri('')).toBeNull(); - }); - - it('should handle DIDs with various characters', () => { - const uri = 'at://did:web:example.com/collection/rkey'; - const result = parseAtUri(uri); - - expect(result).toEqual({ - did: 'did:web:example.com', - collection: 'collection', - rkey: 'rkey', - }); - }); + it('should parse AT-URI with rkey', () => { + const uri = 'at://did:plc:abc123/sh.tangled.repo.issue/xyz789'; + const result = parseAtUri(uri); + + expect(result).toEqual({ + did: 'did:plc:abc123', + collection: 'sh.tangled.repo.issue', + rkey: 'xyz789', + }); + }); + + it('should parse AT-URI without rkey', () => { + const uri = 'at://did:plc:abc123/sh.tangled.repo'; + const result = parseAtUri(uri); + + expect(result).toEqual({ + did: 'did:plc:abc123', + collection: 'sh.tangled.repo', + }); + }); + + it('should parse AT-URI with nested collection', () => { + const uri = 'at://did:plc:abc123/sh.tangled.repo.issue.state/xyz'; + const result = parseAtUri(uri); + + expect(result).toEqual({ + did: 'did:plc:abc123', + collection: 'sh.tangled.repo.issue.state', + rkey: 'xyz', + }); + }); + + it('should return null for invalid URI', () => { + expect(parseAtUri('not-a-uri')).toBeNull(); + expect(parseAtUri('http://example.com')).toBeNull(); + expect(parseAtUri('at://invalid-did/collection')).toBeNull(); + expect(parseAtUri('')).toBeNull(); + }); + + it('should handle DIDs with various characters', () => { + const uri = 'at://did:web:example.com/collection/rkey'; + const result = parseAtUri(uri); + + expect(result).toEqual({ + did: 'did:web:example.com', + collection: 'collection', + rkey: 'rkey', + }); + }); }); describe('resolveHandleToDid', () => { - let mockClient: TangledApiClient; - - beforeEach(() => { - mockClient = createMockClient(); - }); - - it('should resolve handle to DID', async () => { - const mockResolve = vi.fn().mockResolvedValue({ - data: { did: 'did:plc:abc123' }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - identity: { - resolveHandle: mockResolve, - }, - }, - }, - } as never); - - const result = await resolveHandleToDid('mark.bsky.social', mockClient); - - expect(result).toBe('did:plc:abc123'); - expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); - }); - - it('should strip leading @ from handle', async () => { - const mockResolve = vi.fn().mockResolvedValue({ - data: { did: 'did:plc:abc123' }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - identity: { - resolveHandle: mockResolve, - }, - }, - }, - } as never); - - await resolveHandleToDid('@mark.bsky.social', mockClient); - - expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); - }); - - it('should throw error when handle not found', async () => { - const mockResolve = vi.fn().mockResolvedValue({ - data: { did: null }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - identity: { - resolveHandle: mockResolve, - }, - }, - }, - } as never); - - await expect( - resolveHandleToDid('nonexistent.bsky.social', mockClient), - ).rejects.toThrow('No DID found for handle: nonexistent.bsky.social'); - }); - - it('should throw error on network failure', async () => { - const mockResolve = vi - .fn() - .mockRejectedValue(new Error('Network error')); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - identity: { - resolveHandle: mockResolve, - }, - }, - }, - } as never); - - await expect( - resolveHandleToDid('mark.bsky.social', mockClient), - ).rejects.toThrow( - "Failed to resolve handle 'mark.bsky.social': Network error", - ); - }); + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(); + }); + + it('should resolve handle to DID', async () => { + const mockResolve = vi.fn().mockResolvedValue({ + data: { did: 'did:plc:abc123' }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + identity: { + resolveHandle: mockResolve, + }, + }, + }, + } as never); + + const result = await resolveHandleToDid('mark.bsky.social', mockClient); + + expect(result).toBe('did:plc:abc123'); + expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); + }); + + it('should strip leading @ from handle', async () => { + const mockResolve = vi.fn().mockResolvedValue({ + data: { did: 'did:plc:abc123' }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + identity: { + resolveHandle: mockResolve, + }, + }, + }, + } as never); + + await resolveHandleToDid('@mark.bsky.social', mockClient); + + expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); + }); + + it('should throw error when handle not found', async () => { + const mockResolve = vi.fn().mockResolvedValue({ + data: { did: null }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + identity: { + resolveHandle: mockResolve, + }, + }, + }, + } as never); + + await expect(resolveHandleToDid('nonexistent.bsky.social', mockClient)).rejects.toThrow( + 'No DID found for handle: nonexistent.bsky.social' + ); + }); + + it('should throw error on network failure', async () => { + const mockResolve = vi.fn().mockRejectedValue(new Error('Network error')); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + identity: { + resolveHandle: mockResolve, + }, + }, + }, + } as never); + + await expect(resolveHandleToDid('mark.bsky.social', mockClient)).rejects.toThrow( + "Failed to resolve handle 'mark.bsky.social': Network error" + ); + }); }); describe('buildRepoAtUri', () => { - let mockClient: TangledApiClient; - - beforeEach(() => { - mockClient = createMockClient(); - }); - - it('should build AT-URI from DID', async () => { - const result = await buildRepoAtUri( - 'did:plc:abc123', - 'my-repo', - mockClient, - ); - - expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); - }); - - it('should build AT-URI from handle', async () => { - const mockResolve = vi.fn().mockResolvedValue({ - data: { did: 'did:plc:abc123' }, - }); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - identity: { - resolveHandle: mockResolve, - }, - }, - }, - } as never); - - const result = await buildRepoAtUri( - 'mark.bsky.social', - 'my-repo', - mockClient, - ); - - expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); - expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); - }); - - it('should handle repository names with special characters', async () => { - const result = await buildRepoAtUri( - 'did:plc:abc123', - 'repo-name_123', - mockClient, - ); - - expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/repo-name_123'); - }); - - it('should throw error when handle resolution fails', async () => { - const mockResolve = vi - .fn() - .mockRejectedValue(new Error('Resolution failed')); - - vi.mocked(mockClient.getAgent).mockReturnValue({ - com: { - atproto: { - identity: { - resolveHandle: mockResolve, - }, - }, - }, - } as never); - - await expect( - buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient), - ).rejects.toThrow( - "Failed to resolve handle 'mark.bsky.social': Resolution failed", - ); - }); + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(); + }); + + it('should build AT-URI from DID', async () => { + const result = await buildRepoAtUri('did:plc:abc123', 'my-repo', mockClient); + + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); + }); + + it('should build AT-URI from handle', async () => { + const mockResolve = vi.fn().mockResolvedValue({ + data: { did: 'did:plc:abc123' }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + identity: { + resolveHandle: mockResolve, + }, + }, + }, + } as never); + + const result = await buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient); + + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); + expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); + }); + + it('should handle repository names with special characters', async () => { + const result = await buildRepoAtUri('did:plc:abc123', 'repo-name_123', mockClient); + + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/repo-name_123'); + }); + + it('should throw error when handle resolution fails', async () => { + const mockResolve = vi.fn().mockRejectedValue(new Error('Resolution failed')); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + identity: { + resolveHandle: mockResolve, + }, + }, + }, + } as never); + + await expect(buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient)).rejects.toThrow( + "Failed to resolve handle 'mark.bsky.social': Resolution failed" + ); + }); }); diff --git a/tests/utils/auth-helpers.test.ts b/tests/utils/auth-helpers.test.ts index ef2bb0d..f6ab7d8 100644 --- a/tests/utils/auth-helpers.test.ts +++ b/tests/utils/auth-helpers.test.ts @@ -1,38 +1,39 @@ import { describe, expect, it, vi } from 'vitest'; -import { requireAuth } from '../../src/utils/auth-helpers.js'; import type { TangledApiClient } from '../../src/lib/api-client.js'; +import { requireAuth } from '../../src/utils/auth-helpers.js'; // Mock API client factory -const createMockClient = (authenticated: boolean, session: { did: string; handle: string } | null): TangledApiClient => { - return { - isAuthenticated: vi.fn(async () => authenticated), - getSession: vi.fn(() => session), - } as unknown as TangledApiClient; +const createMockClient = ( + authenticated: boolean, + session: { did: string; handle: string } | null +): TangledApiClient => { + return { + isAuthenticated: vi.fn(async () => authenticated), + getSession: vi.fn(() => session), + } as unknown as TangledApiClient; }; describe('requireAuth', () => { - it('should return session when authenticated', async () => { - const mockSession = { did: 'did:plc:test123', handle: 'test.bsky.social' }; - const mockClient = createMockClient(true, mockSession); + it('should return session when authenticated', async () => { + const mockSession = { did: 'did:plc:test123', handle: 'test.bsky.social' }; + const mockClient = createMockClient(true, mockSession); - const result = await requireAuth(mockClient); + const result = await requireAuth(mockClient); - expect(result).toEqual(mockSession); - }); + expect(result).toEqual(mockSession); + }); - it('should throw error when not authenticated', async () => { - const mockClient = createMockClient(false, null); + it('should throw error when not authenticated', async () => { + const mockClient = createMockClient(false, null); - await expect(requireAuth(mockClient)).rejects.toThrow( - 'Must be authenticated. Run "tangled auth login" first.', - ); - }); + await expect(requireAuth(mockClient)).rejects.toThrow( + 'Must be authenticated. Run "tangled auth login" first.' + ); + }); - it('should throw error when authenticated but no session', async () => { - const mockClient = createMockClient(true, null); + it('should throw error when authenticated but no session', async () => { + const mockClient = createMockClient(true, null); - await expect(requireAuth(mockClient)).rejects.toThrow( - 'No active session found', - ); - }); + await expect(requireAuth(mockClient)).rejects.toThrow('No active session found'); + }); }); diff --git a/tests/utils/body-input.test.ts b/tests/utils/body-input.test.ts index 36d5da9..b9c2a0e 100644 --- a/tests/utils/body-input.test.ts +++ b/tests/utils/body-input.test.ts @@ -5,118 +5,116 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { readBodyInput } from '../../src/utils/body-input.js'; describe('readBodyInput', () => { - describe('direct string input', () => { - it('should return body string when provided', async () => { - const result = await readBodyInput('Test body content'); - expect(result).toBe('Test body content'); - }); - - it('should return multiline body string', async () => { - const multiline = 'Line 1\nLine 2\nLine 3'; - const result = await readBodyInput(multiline); - expect(result).toBe(multiline); - }); - - it('should return empty string', async () => { - const result = await readBodyInput(''); - expect(result).toBe(''); - }); - }); - - describe('file input', () => { - let tempDir: string; - let testFile: string; - - beforeEach(async () => { - // Create a temporary directory for test files - tempDir = path.join(process.cwd(), 'tests', 'fixtures', 'temp'); - await fs.mkdir(tempDir, { recursive: true }); - testFile = path.join(tempDir, 'test-body.txt'); - }); - - afterEach(async () => { - // Clean up test files - try { - await fs.rm(tempDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - }); - - it('should read content from file', async () => { - const content = 'File content here'; - await fs.writeFile(testFile, content, 'utf-8'); - - const result = await readBodyInput(undefined, testFile); - expect(result).toBe(content); - }); - - it('should read multiline content from file', async () => { - const content = 'Line 1\nLine 2\nLine 3'; - await fs.writeFile(testFile, content, 'utf-8'); - - const result = await readBodyInput(undefined, testFile); - expect(result).toBe(content); - }); - - it('should read empty file', async () => { - await fs.writeFile(testFile, '', 'utf-8'); - - const result = await readBodyInput(undefined, testFile); - expect(result).toBe(''); - }); - - it('should throw error when file does not exist', async () => { - const nonExistentFile = path.join(tempDir, 'does-not-exist.txt'); - - await expect(readBodyInput(undefined, nonExistentFile)).rejects.toThrow( - `File not found: ${nonExistentFile}`, - ); - }); - - it('should throw error when path is a directory', async () => { - await expect(readBodyInput(undefined, tempDir)).rejects.toThrow( - `'${tempDir}' is a directory, not a file`, - ); - }); - }); - - describe('stdin input', () => { - // Note: Stdin reading is tested via integration tests - // Mocking process.stdin is complex and unreliable in unit tests - // The implementation is straightforward and covered by: - // 1. File I/O tests (same event-driven patterns) - // 2. Integration tests with real stdin - it.skip('stdin reading is tested via integration tests', () => { - // Placeholder to document testing approach - }); - }); - - describe('no input', () => { - it('should return undefined when no input provided', async () => { - const result = await readBodyInput(); - expect(result).toBeUndefined(); - }); - - it('should return undefined when both params are undefined', async () => { - const result = await readBodyInput(undefined, undefined); - expect(result).toBeUndefined(); - }); - }); - - describe('error cases', () => { - it('should throw error when both bodyString and bodyFilePath provided', async () => { - await expect( - readBodyInput('body text', '/path/to/file'), - ).rejects.toThrow( - 'Cannot specify both --body and --body-file. Choose one input method.', - ); - }); - - it('should throw error when both bodyString and stdin flag provided', async () => { - await expect(readBodyInput('body text', '-')).rejects.toThrow( - 'Cannot specify both --body and --body-file. Choose one input method.', - ); - }); - }); + describe('direct string input', () => { + it('should return body string when provided', async () => { + const result = await readBodyInput('Test body content'); + expect(result).toBe('Test body content'); + }); + + it('should return multiline body string', async () => { + const multiline = 'Line 1\nLine 2\nLine 3'; + const result = await readBodyInput(multiline); + expect(result).toBe(multiline); + }); + + it('should return empty string', async () => { + const result = await readBodyInput(''); + expect(result).toBe(''); + }); + }); + + describe('file input', () => { + let tempDir: string; + let testFile: string; + + beforeEach(async () => { + // Create a temporary directory for test files + tempDir = path.join(process.cwd(), 'tests', 'fixtures', 'temp'); + await fs.mkdir(tempDir, { recursive: true }); + testFile = path.join(tempDir, 'test-body.txt'); + }); + + afterEach(async () => { + // Clean up test files + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should read content from file', async () => { + const content = 'File content here'; + await fs.writeFile(testFile, content, 'utf-8'); + + const result = await readBodyInput(undefined, testFile); + expect(result).toBe(content); + }); + + it('should read multiline content from file', async () => { + const content = 'Line 1\nLine 2\nLine 3'; + await fs.writeFile(testFile, content, 'utf-8'); + + const result = await readBodyInput(undefined, testFile); + expect(result).toBe(content); + }); + + it('should read empty file', async () => { + await fs.writeFile(testFile, '', 'utf-8'); + + const result = await readBodyInput(undefined, testFile); + expect(result).toBe(''); + }); + + it('should throw error when file does not exist', async () => { + const nonExistentFile = path.join(tempDir, 'does-not-exist.txt'); + + await expect(readBodyInput(undefined, nonExistentFile)).rejects.toThrow( + `File not found: ${nonExistentFile}` + ); + }); + + it('should throw error when path is a directory', async () => { + await expect(readBodyInput(undefined, tempDir)).rejects.toThrow( + `'${tempDir}' is a directory, not a file` + ); + }); + }); + + describe('stdin input', () => { + // Note: Stdin reading is tested via integration tests + // Mocking process.stdin is complex and unreliable in unit tests + // The implementation is straightforward and covered by: + // 1. File I/O tests (same event-driven patterns) + // 2. Integration tests with real stdin + it.skip('stdin reading is tested via integration tests', () => { + // Placeholder to document testing approach + }); + }); + + describe('no input', () => { + it('should return undefined when no input provided', async () => { + const result = await readBodyInput(); + expect(result).toBeUndefined(); + }); + + it('should return undefined when both params are undefined', async () => { + const result = await readBodyInput(undefined, undefined); + expect(result).toBeUndefined(); + }); + }); + + describe('error cases', () => { + it('should throw error when both bodyString and bodyFilePath provided', async () => { + await expect(readBodyInput('body text', '/path/to/file')).rejects.toThrow( + 'Cannot specify both --body and --body-file. Choose one input method.' + ); + }); + + it('should throw error when both bodyString and stdin flag provided', async () => { + await expect(readBodyInput('body text', '-')).rejects.toThrow( + 'Cannot specify both --body and --body-file. Choose one input method.' + ); + }); + }); }); diff --git a/tests/utils/validation.test.ts b/tests/utils/validation.test.ts index 61b5dc3..3817252 100644 --- a/tests/utils/validation.test.ts +++ b/tests/utils/validation.test.ts @@ -194,7 +194,9 @@ describe('Issue Validation', () => { it('should reject titles over 256 characters', () => { const tooLong = 'A'.repeat(257); - expect(() => validateIssueTitle(tooLong)).toThrow('Issue title must be 256 characters or less'); + expect(() => validateIssueTitle(tooLong)).toThrow( + 'Issue title must be 256 characters or less' + ); }); }); @@ -215,7 +217,9 @@ describe('Issue Validation', () => { it('should reject bodies over 50,000 characters', () => { const tooLong = 'A'.repeat(50001); - expect(() => validateIssueBody(tooLong)).toThrow('Issue body must be 50,000 characters or less'); + expect(() => validateIssueBody(tooLong)).toThrow( + 'Issue body must be 50,000 characters or less' + ); }); }); });