diff --git a/src/lib/issues-api.ts b/src/lib/issues-api.ts new file mode 100644 index 0000000..28d1ef5 --- /dev/null +++ b/src/lib/issues-api.ts @@ -0,0 +1,353 @@ +import type { TangledApiClient } from './api-client.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'; + +// Re-export the generated issue record type for convenience +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 +} + +/** + * Parameters for creating an issue + */ +export interface CreateIssueParams { + client: TangledApiClient; + repoAtUri: string; + title: string; + body?: string; +} + +/** + * Parameters for listing issues + */ +export interface ListIssuesParams { + client: TangledApiClient; + repoAtUri: string; + limit?: number; + cursor?: string; +} + +/** + * Parameters for getting a specific issue + */ +export interface GetIssueParams { + client: TangledApiClient; + issueUri: string; +} + +/** + * Parameters for updating an issue + */ +export interface UpdateIssueParams { + client: TangledApiClient; + issueUri: string; + title?: string; + body?: string; +} + +/** + * Parameters for closing an issue + */ +export interface CloseIssueParams { + client: TangledApiClient; + issueUri: string; +} + +/** + * Parameters for deleting an issue + */ +export interface DeleteIssueParams { + client: TangledApiClient; + issueUri: string; +} + +/** + * Parse and validate an issue AT-URI + * @throws Error if URI is invalid or missing rkey + * @returns Parsed URI components + */ +function parseIssueUri(issueUri: 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, + }; +} + +/** + * 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'); + } +} + +/** + * List issues for a repository + */ +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'); + } +} + +/** + * 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'); + } +} + +/** + * 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'); + } +} + +/** + * 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'); + } +} + +/** + * 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'); + } +} diff --git a/tests/lib/issues-api.test.ts b/tests/lib/issues-api.test.ts new file mode 100644 index 0000000..c011737 --- /dev/null +++ b/tests/lib/issues-api.test.ts @@ -0,0 +1,671 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + 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; +}; + +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'); + }); +}); + +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'); + }); +}); + +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'); + }); +}); + +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'); + }); +}); + +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'); + }); +}); + +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'); + }); +});