mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

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