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