import { test, before, after } from 'node:test' import assert from 'node:assert/strict' import { createHmac } from 'node:crypto' import express from 'express' import { startFakeGitea } from './helpers/fakeGitea.js' import { startFakeAppwrite } from './helpers/fakeAppwrite.js' import { mailSink } from './support/mailSink.js' const TOKEN = 's3cret' const NOW = new Date() const ago = (h) => new Date(NOW.getTime() - h * 3600_000).toISOString() let gitea, aw, server, baseUrl, setMailTransportForTests, runRepoAssignJob, sink async function hook(event, payload) { const body = JSON.stringify(payload) const res = await fetch(`${baseUrl}/webhook/gitea`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Gitea-Event': event, 'X-Gitea-Signature': createHmac('sha256', TOKEN).update(body).digest('hex'), }, body, }) return { status: res.status, body: await res.json().catch(() => null) } } const created = (full, extra = {}) => ({ action: 'created', repository: { full_name: full, name: full.split('/')[1], html_url: `${gitea.baseUrl}/${full}`, created_at: NOW.toISOString(), owner: { login: full.split('/')[0] } }, sender: { login: 'knso' }, ...extra, }) const docsOf = (full) => aw.all('websiteProjects').filter((d) => d.repoFullName === full) before(async () => { gitea = await startFakeGitea({ repos: [] }) aw = await startFakeAppwrite({ seed: { websiteProjects: [ { $id: 'waise', repoFullName: 'WEBklar/schmacko-napf', projectName: 'schmacko-napf', ticketId: '', customerId: '' }, { $id: 'verknuepft', repoFullName: 'WEBklar/mit-ticket', projectName: 'mit-ticket', ticketId: 't-9' }, { $id: 'lebt', repoFullName: 'WEBklar/lebt-noch', projectName: 'lebt-noch' }, ], employees: [{ $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com' }], }, }) Object.assign(process.env, { GITEA_BASE_URL: gitea.baseUrl, GITEA_API_TOKEN: 'test-token', GITEA_WEBHOOK_TOKEN: TOKEN, APPWRITE_ENDPOINT: aw.endpoint, APPWRITE_API_KEY: 'test-key', PREVIEW_DEPLOY_ROOT: '', TRAEFIK_DYNAMIC_DIR: '', }) const [{ default: routes }, mailer, job] = await Promise.all([ import('../server/routes/webhook/gitea.js'), import('../server/services/mailer.js'), import('../server/services/repoAssignJob.js'), ]) setMailTransportForTests = mailer.setMailTransportForTests runRepoAssignJob = job.runRepoAssignJob const app = express() app.use(express.json({ verify: (req, _r, buf) => { req.rawBody = buf } })) app.use('/webhook', routes) server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)) }) baseUrl = `http://127.0.0.1:${server.address().port}` }) after(async () => { setMailTransportForTests?.(null) await new Promise((r) => server.close(r)) await gitea.close() await aw.close() }) const freshSink = () => { sink = mailSink(); setMailTransportForTests(sink) } test('repository/created → 1 Dokument + 1 Mail; Wiederholung → keine zweite', async () => { freshSink() const r = await hook('repository', created('knso/neu1')) assert.equal(r.status, 200) assert.equal(docsOf('knso/neu1').length, 1) assert.equal(docsOf('knso/neu1')[0].createdByGitea, 'knso') assert.equal(sink.sent.length, 1) assert.match(sink.sent[0].html, /ticket\.webklar\.com\/projects\/zuordnen\//) await hook('repository', created('knso/neu1')) assert.equal(docsOf('knso/neu1').length, 1) assert.equal(sink.sent.length, 1) }) test('gleichzeitig Webhook + Job für dasselbe Repo → 1 Dokument, 1 Mail', async () => { freshSink() gitea.addRepo({ full_name: 'knso/race', created_at: NOW.toISOString() }) await Promise.all([hook('repository', created('knso/race')), runRepoAssignJob({ now: NOW }), hook('repository', created('knso/race'))]) assert.equal(docsOf('knso/race').length, 1) assert.equal(sink.sent.length, 1) }) test('repository/deleted und ohne Signatur', async () => { freshSink() const before = aw.all('websiteProjects').length const r = await hook('repository', { ...created('knso/weg'), action: 'deleted' }) assert.equal(r.status, 200) assert.equal(aw.all('websiteProjects').length, before) assert.equal(sink.sent.length, 0) const res = await fetch(`${baseUrl}/webhook/gitea`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Gitea-Event': 'repository' }, body: '{}' }) assert.equal(res.status, 401) }) test('Job: 20 alte Repos → 0 Mails', async () => { freshSink() for (let i = 0; i < 20; i++) gitea.addRepo({ full_name: `knso/alt${i}`, created_at: ago(72) }) const c = await runRepoAssignJob({ now: NOW }) assert.equal(c.imported, 20) assert.equal(c.mails, 0) assert.equal(sink.sent.length, 0) assert.ok(!docsOf('knso/alt0')[0].createdByGitea, 'alte Repos bekommen kein createdByGitea') }) test('Job: Mail ohne vorherigen Versuch wird nachgeholt (createdByGitea, 0 Versuche)', async () => { freshSink() gitea.addRepo({ full_name: 'knso/nachholen', created_at: ago(2) }) await runRepoAssignJob({ now: NOW }) const [doc] = docsOf('knso/nachholen') // Lauf abgebrochen, bevor die erste Mail versucht wurde aw.all('websiteProjects').find((d) => d.$id === doc.$id).assignMailSentAt = '' Object.assign(aw.all('websiteProjects').find((d) => d.$id === doc.$id), { assignMailAttempts: 0 }) freshSink() const c = await runRepoAssignJob({ now: NOW }) assert.equal(c.retries, 1) assert.equal(sink.sent.length, 1) }) test('Job: vom System-Konto angelegtes Projekt ohne Art wird nicht abgefragt und nicht gemailt', async () => { freshSink() gitea.addRepo({ full_name: 'WEBklar/aus-womS', created_at: ago(1) }) aw.insert('websiteProjects', { repoFullName: 'WEBklar/aus-womS', projectName: 'aus WOMS', createdByGitea: 'webklar-system', assignMailAttempts: 0 }) const before = gitea.calls.filter((c) => c.path === '/api/v1/repos/WEBklar/aus-womS').length const c = await runRepoAssignJob({ now: NOW }) assert.equal(c.retries, 0) assert.equal(sink.sent.length, 0) assert.equal(gitea.calls.filter((x) => x.path === '/api/v1/repos/WEBklar/aus-womS').length, before) }) test('Job: neues Nutzer-Repo → Mail, Org-Repo → keine Mail', async () => { freshSink() gitea.addRepo({ full_name: 'knso/jobneu', created_at: ago(1) }) gitea.addRepo({ full_name: 'WEBklar/orgneu', created_at: ago(1) }) const c = await runRepoAssignJob({ now: NOW }) assert.equal(c.mails, 1) assert.equal(sink.sent.length, 1) assert.equal(docsOf('knso/jobneu')[0].createdByGitea, 'knso') assert.ok(!docsOf('WEBklar/orgneu')[0].createdByGitea) assert.equal((await runRepoAssignJob({ now: NOW })).mails, 0) }) test('Job: Erinnerung nach 24 h genau einmal', async () => { freshSink() await runRepoAssignJob({ now: NOW }) assert.equal(sink.sent.length, 0) const later = new Date(NOW.getTime() + 25 * 3600_000) assert.equal((await runRepoAssignJob({ now: later })).reminders >= 1, true) const n = sink.sent.length assert.ok(n >= 1) assert.equal((await runRepoAssignJob({ now: later })).reminders, 0) assert.equal(sink.sent.length, n) }) test('Job: fehlgeschlagene erste Mail wird wiederholt (< 3 Versuche)', async () => { gitea.addRepo({ full_name: 'knso/retry', created_at: ago(1) }) setMailTransportForTests({ async sendMail() { throw new Error('smtp down') } }) const silent = console.error; console.error = () => {} try { await runRepoAssignJob({ now: NOW }) } finally { console.error = silent } assert.equal(docsOf('knso/retry')[0].assignMailAttempts, 1) assert.ok(!docsOf('knso/retry')[0].assignMailSentAt) freshSink() const c = await runRepoAssignJob({ now: NOW }) assert.equal(c.retries, 1) assert.equal(sink.sent.length, 1) assert.ok(docsOf('knso/retry')[0].assignMailSentAt) }) test('Job: Appwrite-Fehler bricht den Lauf ab, ohne zu werfen', async () => { const silent = console.error; console.error = () => {} const awUrl = process.env.APPWRITE_ENDPOINT try { const { config } = await import('../server/config.js') const old = config.appwrite.endpoint config.appwrite.endpoint = 'http://127.0.0.1:1/v1' const c = await runRepoAssignJob({ now: NOW }) config.appwrite.endpoint = old assert.ok(c.error) } finally { console.error = silent } assert.ok(awUrl) }) test('repository/deleted entfernt das unverknuepfte Platzhalter-Dokument, verknuepfte und lebende bleiben', async () => { gitea.addRepo({ full_name: 'WEBklar/lebt-noch' }) const del = (full) => hook('repository', { ...created(full), action: 'deleted', sender: { login: 'ANDJ' } }) const r = await del('WEBklar/schmacko-napf') assert.equal(r.status, 200) assert.equal(r.body.removed, 1) assert.equal(aw.get('websiteProjects', 'waise'), undefined) assert.equal((await del('WEBklar/mit-ticket')).body.removed, 0) assert.ok(aw.get('websiteProjects', 'verknuepft')) assert.equal((await del('WEBklar/lebt-noch')).body.removed, 0) assert.ok(aw.get('websiteProjects', 'lebt')) })