mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

1import crypto from 'node:crypto' 2import process from 'node:process' 3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 4import { installation, repoMapping } from '../../server/db/schema' 5import { clearDb, setDb, useDb } from '../../server/utils/db' 6import { clearEncryptionKeyCache } from '../../server/utils/encryption' 7import { RemoteRejectedError } from '../../server/utils/git-wire/errors' 8import { createTestDb } from '../utils/db' 9 10const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY 11 12const fetchAdvertisementMock = vi.fn<(repo: string, token: string) => Promise<{ refs: Map<string, string> }>>() 13const splicePushMock = vi.fn<(params: Record<string, unknown>) => Promise<{ status: string, sha: string }>>() 14const spliceDeleteMock = vi.fn<(params: Record<string, unknown>) => Promise<{ status: string }>>() 15const octokitAuthMock = vi.fn<(input: { type: 'installation' }) => Promise<{ token: string }>>() 16 17vi.mock('../../server/utils/splice', () => ({ 18 fetchAdvertisement: (repo: string, token: string) => fetchAdvertisementMock(repo, token), 19 splicePush: (params: Record<string, unknown>) => splicePushMock(params), 20 spliceDelete: (params: Record<string, unknown>) => spliceDeleteMock(params), 21})) 22 23vi.mock('../../server/utils/github-app', () => ({ 24 installationOctokit: async () => ({ auth: octokitAuthMock }), 25})) 26 27const { syncCreateRef, syncDeleteRef } = await import('../../server/utils/sync-ref') 28 29describe('sync-ref', () => { 30 beforeEach(async () => { 31 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 32 clearEncryptionKeyCache() 33 34 setDb(await createTestDb()) 35 const db = useDb() 36 await db.insert(installation).values({ 37 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 38 }) 39 40 fetchAdvertisementMock.mockReset() 41 splicePushMock.mockReset() 42 spliceDeleteMock.mockReset() 43 octokitAuthMock.mockReset() 44 45 octokitAuthMock.mockResolvedValue({ token: 'install-token' }) 46 fetchAdvertisementMock.mockResolvedValue({ 47 refs: new Map([ 48 ['refs/heads/main', 'a'.repeat(40)], 49 ['refs/heads/feature-x', 'b'.repeat(40)], 50 ['refs/tags/v1.0.0', 'c'.repeat(40)], 51 ]), 52 }) 53 splicePushMock.mockResolvedValue({ status: 'synced', sha: 'a'.repeat(40) }) 54 spliceDeleteMock.mockResolvedValue({ status: 'synced' }) 55 }) 56 57 afterEach(() => { 58 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 59 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 60 clearEncryptionKeyCache() 61 clearDb() 62 }) 63 64 async function seedMapping(over: Partial<typeof repoMapping.$inferInsert> = {}) { 65 const db = useDb() 66 await db.insert(repoMapping).values({ 67 installationId: 1, 68 githubRepoId: 9001, 69 githubFullName: 'alice/my-project', 70 tangledRepoDid: 'did:plc:repo-xyz', 71 tangledFullName: 'did:plc:abc/my-project', 72 knot: 'knot1.tangled.sh', 73 status: 'active', 74 ...over, 75 }) 76 } 77 78 describe('syncCreateRef', () => { 79 it('skips non-branch/tag ref types', async () => { 80 await seedMapping() 81 const result = await syncCreateRef({ 82 installationId: 1, githubRepoId: 9001, refType: 'repository' as never, ref: 'whatever', 83 }) 84 expect(result).toEqual({ status: 'skipped', reason: 'not-branch-or-tag' }) 85 expect(splicePushMock).not.toHaveBeenCalled() 86 }) 87 88 it('skips when no mapping exists', async () => { 89 const result = await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }) 90 expect(result).toEqual({ status: 'skipped', reason: 'no-mapping' }) 91 }) 92 93 it('skips when mapping is disabled', async () => { 94 await seedMapping({ disabledAt: new Date() }) 95 const result = await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }) 96 expect(result).toEqual({ status: 'skipped', reason: 'disabled' }) 97 }) 98 99 it('resolves a branch ref to its SHA and splices it', async () => { 100 await seedMapping() 101 await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'feature-x' }) 102 expect(splicePushMock).toHaveBeenCalledWith(expect.objectContaining({ 103 ref: 'refs/heads/feature-x', 104 want: 'b'.repeat(40), 105 repoDid: 'did:plc:repo-xyz', 106 })) 107 }) 108 109 it('resolves a tag ref to its SHA', async () => { 110 await seedMapping() 111 await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'tag', ref: 'v1.0.0' }) 112 expect(splicePushMock).toHaveBeenCalledWith(expect.objectContaining({ 113 ref: 'refs/tags/v1.0.0', 114 want: 'c'.repeat(40), 115 })) 116 }) 117 118 it('retries (throws) when github does not yet advertise the ref', async () => { 119 await seedMapping() 120 fetchAdvertisementMock.mockResolvedValue({ refs: new Map() }) 121 await expect( 122 syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }), 123 ).rejects.toThrow(/does not yet advertise/) 124 expect(splicePushMock).not.toHaveBeenCalled() 125 }) 126 127 it('updates lastSyncedRefs with the synced SHA', async () => { 128 await seedMapping() 129 splicePushMock.mockResolvedValue({ status: 'synced', sha: 'd'.repeat(40) }) 130 await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }) 131 132 const rows = await useDb().select().from(repoMapping) 133 expect((rows[0]!.lastSyncedRefs as Record<string, string>)['refs/heads/main']).toBe('d'.repeat(40)) 134 }) 135 136 it('marks mapping error when the knot reports repo gone', async () => { 137 await seedMapping() 138 splicePushMock.mockRejectedValue(new RemoteRejectedError('gone', 'repo-gone')) 139 const result = await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }) 140 expect(result).toEqual({ status: 'skipped', reason: 'repo-gone' }) 141 const rows = await useDb().select().from(repoMapping) 142 expect(rows[0]!.status).toBe('error') 143 }) 144 145 it('rethrows a transient stale-old-sha for queue retry', async () => { 146 await seedMapping() 147 splicePushMock.mockRejectedValue(new RemoteRejectedError('stale', 'stale-old-sha')) 148 await expect( 149 syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }), 150 ).rejects.toMatchObject({ reason: 'stale-old-sha' }) 151 }) 152 }) 153 154 describe('syncDeleteRef', () => { 155 it('splices a delete for the qualified ref', async () => { 156 await seedMapping() 157 await syncDeleteRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'old-branch' }) 158 expect(spliceDeleteMock).toHaveBeenCalledWith(expect.objectContaining({ ref: 'refs/heads/old-branch' })) 159 }) 160 161 it('treats an already-absent ref as success', async () => { 162 await seedMapping() 163 spliceDeleteMock.mockResolvedValue({ status: 'already-absent' }) 164 const result = await syncDeleteRef({ installationId: 1, githubRepoId: 9001, refType: 'tag', ref: 'v9' }) 165 expect(result).toEqual({ status: 'synced' }) 166 }) 167 168 it('removes the ref from lastSyncedRefs', async () => { 169 await seedMapping({ lastSyncedRefs: { 'refs/heads/main': 'abc', 'refs/heads/old': 'def' } }) 170 await syncDeleteRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'old' }) 171 const rows = await useDb().select().from(repoMapping) 172 expect(rows[0]!.lastSyncedRefs).toEqual({ 'refs/heads/main': 'abc' }) 173 }) 174 175 it('marks mapping as error if knot reports repo gone', async () => { 176 await seedMapping() 177 spliceDeleteMock.mockRejectedValue(new RemoteRejectedError('gone', 'repo-gone')) 178 const result = await syncDeleteRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }) 179 expect(result).toEqual({ status: 'skipped', reason: 'repo-gone' }) 180 const rows = await useDb().select().from(repoMapping) 181 expect(rows[0]!.status).toBe('error') 182 }) 183 }) 184})