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 10const ZERO = '0'.repeat(40) 11 12const splicePushMock = vi.fn<(params: Record<string, unknown>) => Promise<{ status: string, sha: string }>>() 13const octokitAuthMock = vi.fn<(input: { type: 'installation' }) => Promise<{ token: string }>>() 14 15vi.mock('../../server/utils/splice', () => ({ 16 splicePush: (params: Record<string, unknown>) => splicePushMock(params), 17})) 18 19vi.mock('../../server/utils/github-app', () => ({ 20 installationOctokit: async () => ({ auth: octokitAuthMock }), 21})) 22 23const { syncPush } = await import('../../server/utils/sync-push') 24 25describe('sync-push', () => { 26 beforeEach(async () => { 27 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 28 clearEncryptionKeyCache() 29 30 setDb(await createTestDb()) 31 await useDb().insert(installation).values({ 32 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 33 }) 34 35 splicePushMock.mockReset() 36 octokitAuthMock.mockReset() 37 octokitAuthMock.mockResolvedValue({ token: 'install-token' }) 38 splicePushMock.mockResolvedValue({ status: 'synced', sha: 'a'.repeat(40) }) 39 }) 40 41 afterEach(() => { 42 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 43 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 44 clearEncryptionKeyCache() 45 clearDb() 46 }) 47 48 async function seedMapping(over: Partial<typeof repoMapping.$inferInsert> = {}) { 49 await useDb().insert(repoMapping).values({ 50 installationId: 1, 51 githubRepoId: 9001, 52 githubFullName: 'alice/my-project', 53 tangledRepoDid: 'did:plc:repo-xyz', 54 tangledFullName: 'did:plc:abc/my-project', 55 knot: 'knot1.tangled.sh', 56 status: 'active', 57 ...over, 58 }) 59 } 60 61 const payload = (over: Record<string, unknown> = {}) => ({ 62 installationId: 1, 63 githubRepoId: 9001, 64 ref: 'refs/heads/main', 65 before: ZERO, 66 after: 'a'.repeat(40), 67 ...over, 68 }) 69 70 it('skips when no mapping exists', async () => { 71 expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'no-mapping' }) 72 expect(splicePushMock).not.toHaveBeenCalled() 73 }) 74 75 it('skips when disabled', async () => { 76 await seedMapping({ disabledAt: new Date() }) 77 expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'disabled' }) 78 }) 79 80 it('skips ref deletions (after = zero sha)', async () => { 81 await seedMapping() 82 expect(await syncPush(payload({ after: ZERO }))).toEqual({ status: 'skipped', reason: 'deletion' }) 83 expect(splicePushMock).not.toHaveBeenCalled() 84 }) 85 86 it('dedupes a redelivery via lastSyncedRefs', async () => { 87 await seedMapping({ lastSyncedRefs: { 'refs/heads/main': 'a'.repeat(40) } }) 88 expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'already-synced' }) 89 expect(splicePushMock).not.toHaveBeenCalled() 90 }) 91 92 it('splices the push and records the new tip', async () => { 93 await seedMapping() 94 const result = await syncPush(payload()) 95 expect(result).toEqual({ status: 'synced' }) 96 expect(splicePushMock).toHaveBeenCalledWith(expect.objectContaining({ 97 ref: 'refs/heads/main', 98 want: 'a'.repeat(40), 99 repoDid: 'did:plc:repo-xyz', 100 knot: 'knot1.tangled.sh', 101 token: 'install-token', 102 })) 103 const rows = await useDb().select().from(repoMapping) 104 expect((rows[0].lastSyncedRefs as Record<string, string>)['refs/heads/main']).toBe('a'.repeat(40)) 105 }) 106 107 it('marks mapping error and stops on a terminal too-big failure', async () => { 108 await seedMapping() 109 splicePushMock.mockRejectedValue(new RemoteRejectedError('pack exceeded', 'too-big')) 110 expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'repo-gone' }) 111 const rows = await useDb().select().from(repoMapping) 112 expect(rows[0].status).toBe('error') 113 expect(rows[0].lastError).toMatch(/size limit/) 114 }) 115 116 it('marks mapping error when the knot rejects our key', async () => { 117 await seedMapping() 118 splicePushMock.mockRejectedValue(new RemoteRejectedError('denied', 'auth-rejected')) 119 expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'repo-gone' }) 120 const rows = await useDb().select().from(repoMapping) 121 expect(rows[0].status).toBe('error') 122 }) 123 124 it('rethrows a transient stale-old-sha for queue retry', async () => { 125 await seedMapping() 126 splicePushMock.mockRejectedValue(new RemoteRejectedError('stale', 'stale-old-sha')) 127 await expect(syncPush(payload())).rejects.toMatchObject({ reason: 'stale-old-sha' }) 128 const rows = await useDb().select().from(repoMapping) 129 expect(rows[0].status).toBe('active') 130 }) 131})