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