From f4c8b235b99685c6a25e28339020bb9282c08be4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:23:01 +0000 Subject: [PATCH] Projekte im Ticketsystem: Anlegen, Archiv und Anzeige repariert - Neues Projekt (Projekte-Seite): Repo-Name blieb nach dem ersten Buchstaben der Subdomain stehen ("m" statt "musterfirma") - Ticket "Projekt waehlen": archivierte Repos nach Flag ausblenden, nicht nach status (reaktivierte fehlten, archivierte wurden angeboten) - Projektliste: "Aktive Projekte" + neue Ansicht "Alle inkl. Archiv"; Liste wird auch nach einem Anlege-Fehler neu geladen - Kundenauswahl: "Kein Kunde" entfernt Kunde und Ticket wirklich (mit Rueckfrage); beim Kundenwechsel bleibt kein fremdes Ticket haengen - Zuordnen-Assistent ueberschreibt eine vorhandene Website nicht mehr - Neues Projekt am Ticket bekommt die Art Website - Umlaute in Subdomains wie im Portal (mueller statt m-ller) - Preview-Block nur mit gespeicherter Subdomain; archivierte Projekte erst reaktivieren, bevor sie bearbeitet werden - Repo-Import beim Aufklappen hoechstens einmal pro Minute Co-Authored-By: Claude Opus 5.5 --- src/components/TicketProjects.jsx | 28 +++-------- src/hooks/useWebsiteProjects.js | 15 +++++- src/lib/projectAdminApi.js | 6 +++ src/lib/projectAssign.js | 28 ++++++++++- src/lib/projectList.js | 28 +++++++++++ src/lib/subdomain.js | 27 +++++----- src/lib/throttleRuns.js | 17 +++++++ src/pages/AssignProjectPage.jsx | 23 ++++++--- src/pages/ProjectsPage.jsx | 80 +++++++++++++----------------- tests/unit/importThrottle.test.mjs | 31 ++++++++++++ tests/unit/projectAssign.test.mjs | 33 ++++++++++-- tests/unit/projectList.test.mjs | 36 ++++++++++++++ tests/unit/subdomain.test.mjs | 9 +++- 13 files changed, 269 insertions(+), 92 deletions(-) create mode 100644 src/lib/projectList.js create mode 100644 src/lib/throttleRuns.js create mode 100644 tests/unit/importThrottle.test.mjs create mode 100644 tests/unit/projectList.test.mjs diff --git a/src/components/TicketProjects.jsx b/src/components/TicketProjects.jsx index 7d94978..9351f5f 100644 --- a/src/components/TicketProjects.jsx +++ b/src/components/TicketProjects.jsx @@ -1,28 +1,13 @@ import { useState, useEffect, useCallback } from 'react' import { FaPlus, FaLink } from 'react-icons/fa6' import { useWebsiteProjects } from '../hooks/useWebsiteProjects' -import { createProjectFromTemplate, importNewRepos } from '../lib/projectAdminApi' -import { isOutsideOrg, assignSummary } from '../lib/projectAssign' +import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi' +import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign' +import { slugify } from '../lib/subdomain' import AssignedProjectCard from './AssignedProjectCard' import { fetchServices, completeAssignment } from '../lib/projectKindsApi' import { kindLabelFor } from '../lib/assignFlow' -function slugify(v) { - return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100) -} - -// Zuordnen lassen sich echte Repos des gleichen Kunden oder ohne Kunden, die nicht -// archiviert, keine Vorlage und noch nicht an diesem Ticket sind -function assignableProjects(all, ticket, assignedIds) { - return (all || []).filter((p) => - p.repoFullName && - !assignedIds.has(p.$id) && - p.status !== 'archived' && - !p.isTemplate && - (!p.customerId || p.customerId === ticket.customerId) - ) -} - // Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle. // Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt. export default function TicketProjects({ ticket }) { @@ -57,7 +42,7 @@ export default function TicketProjects({ ticket }) { setImporting(true) setImportHint('') try { - await importNewRepos() + await importNewReposThrottled() } catch (err) { if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`) } finally { @@ -73,10 +58,11 @@ export default function TicketProjects({ ticket }) { setError('') try { await action() - await load() } catch (err) { setError(err.message || 'Aktion fehlgeschlagen') } finally { + // Auch nach einem Fehler neu laden: das Repo kann trotzdem entstanden sein + try { await load() } catch { /* Anzeige bleibt wie sie ist */ } setBusy(false) } } @@ -157,6 +143,8 @@ export default function TicketProjects({ ticket }) { subdomain, customerId: ticket.customerId || '', ticketId: ticket.$id, + // Aus der Website-Vorlage mit Subdomain: die Art steht fest (sonst "Art fehlt") + kind: 'website', }) setCreateForm({ displayName: '', subdomain: '' }) setShowCreate(false) diff --git a/src/hooks/useWebsiteProjects.js b/src/hooks/useWebsiteProjects.js index 1241fc1..0992339 100644 --- a/src/hooks/useWebsiteProjects.js +++ b/src/hooks/useWebsiteProjects.js @@ -59,5 +59,18 @@ export function useWebsiteProjects() { } } - return { projects, loading, error, fetchAllProjects, fetchByTicketId, assignProjects, unassignProject } + // Projektliste "Kein Kunde": Kunde UND Ticket entfernen (bewusste Auswahl, mit Rueckfrage) + const clearCustomer = async (projectId) => { + try { + await databases.updateDocument( + DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId, + { customerId: '', ticketId: '', updatedAt: new Date().toISOString() } + ) + return { success: true } + } catch (err) { + return { success: false, error: err.message } + } + } + + return { projects, loading, error, fetchAllProjects, fetchByTicketId, assignProjects, unassignProject, clearCustomer } } diff --git a/src/lib/projectAdminApi.js b/src/lib/projectAdminApi.js index 37ef772..fc8f06f 100644 --- a/src/lib/projectAdminApi.js +++ b/src/lib/projectAdminApi.js @@ -1,4 +1,5 @@ import { account } from './appwrite' +import { throttleRuns } from './throttleRuns' const PROJECT_ADMIN_URL = import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com' @@ -33,6 +34,11 @@ export async function importNewRepos() { return adminFetch('/api/admin/gitea/import-repos', { method: 'POST' }) } +// Jedes Aufklappen eines Tickets fragte frueher alle Gitea-Repos ab (serverseitig unter einer +// globalen Sperre). Neue Repos kommen ohnehin per Webhook und 10-Minuten-Lauf - hier hoechstens +// ein Abgleich pro Minute; ein laufender Abgleich wird geteilt. null = uebersprungen. +export const importNewReposThrottled = throttleRuns(() => importNewRepos(), 60000) + export async function assignWebsiteProject(id, { customerId, ticketId }) { return adminFetch(`/api/admin/website-projects/${id}/assign`, { method: 'POST', diff --git a/src/lib/projectAssign.js b/src/lib/projectAssign.js index 9f0b7f9..dfc28de 100644 --- a/src/lib/projectAssign.js +++ b/src/lib/projectAssign.js @@ -6,8 +6,34 @@ export function isOutsideOrg(repoFullName) { return org.toLowerCase() !== GITEA_ORG.toLowerCase() } +// "Preview einschalten" nur, solange das Projekt noch keine Website hat. Mit Subdomain laeuft die +// Preview schon oder wird gerade gebaut (der Runner setzt 'deployed' erst danach) - Neubauen +// geht dann ueber den Preview-Block. Archivierte Projekte erst reaktivieren. export function needsPreviewButton(project) { - return !project.previewUrl || project.status !== 'deployed' + if (project.archived) return false + return !project.subdomain || !project.previewUrl +} + +// Zuordnen lassen sich echte Repos des gleichen Kunden oder ohne Kunden, die nicht archiviert, +// keine Vorlage und noch nicht an diesem Ticket sind. Massgeblich ist das Flag `archived` +// (nicht status): nach dem Reaktivieren blieb status frueher auf 'archived' stehen. +export function assignableProjects(all, ticket, assignedIds) { + return (all || []).filter((p) => + p.repoFullName && + !assignedIds.has(p.$id) && + !p.archived && + !p.isTemplate && + (!p.customerId || p.customerId === ticket.customerId) + ) +} + +// Kundenwechsel in der Projektliste: das bisherige Ticket nur behalten, wenn es dem neuen Kunden +// gehoert - sonst haengt das Projekt von Kunde B am Ticket von Kunde A. +export function ticketIdForCustomer(project, customerId, workorderById = {}) { + const ticketId = project?.ticketId || '' + if (!ticketId) return '' + if (customerId === project.customerId) return ticketId + return workorderById[ticketId]?.customerId === customerId ? ticketId : '' } export function assignSummary(projectName, result) { diff --git a/src/lib/projectList.js b/src/lib/projectList.js new file mode 100644 index 0000000..218a4db --- /dev/null +++ b/src/lib/projectList.js @@ -0,0 +1,28 @@ +// Ansichten der Projektliste (Projekte-Seite). Jedes Gitea-Repo ist ein Projekt; "Aktive Projekte" +// blendet Archiv und Vorlagen aus, "Alle inkl. Archiv" zeigt wirklich alles (Vorlagen nur Admins). + +const active = (p) => !p.archived && !p.isTemplate + +export function projectCounts(projects, { isAdmin = false } = {}) { + const list = projects || [] + const act = list.filter(active) + return { + all: act.length, + unassigned: act.filter((p) => !p.customerId).length, + assigned: act.filter((p) => Boolean(p.customerId)).length, + archived: list.filter((p) => p.archived && (isAdmin || !p.isTemplate)).length, + templates: list.filter((p) => p.isTemplate && !p.archived).length, + everything: list.filter((p) => isAdmin || !p.isTemplate).length, + } +} + +export function projectsInView(projects, filter, { isAdmin = false } = {}) { + const list = projects || [] + if (filter === 'templates') return list.filter((p) => p.isTemplate && !p.archived) + if (filter === 'archived') return list.filter((p) => p.archived && (isAdmin || !p.isTemplate)) + if (filter === 'everything') return list.filter((p) => isAdmin || !p.isTemplate) + const act = list.filter(active) + if (filter === 'unassigned') return act.filter((p) => !p.customerId) + if (filter === 'assigned') return act.filter((p) => Boolean(p.customerId)) + return act +} diff --git a/src/lib/subdomain.js b/src/lib/subdomain.js index 0a5b2bd..8237d28 100644 --- a/src/lib/subdomain.js +++ b/src/lib/subdomain.js @@ -5,18 +5,21 @@ const UMLAUT_MAP = { ß: 'ss', } +// Subdomain/Repo-Kuerzel wie im Portal (slug.js): Umlaute ausgeschrieben statt "m-ller", +// hoechstens 63 Zeichen (Laenge eines DNS-Labels), ohne Bindestrich am Rand. +export function slugify(value) { + let v = String(value ?? '').trim().toLowerCase() + if (!v) return '' + v = v.replace(/[äöüß]/g, (ch) => UMLAUT_MAP[ch]) + v = v.normalize('NFD').replace(/[\u0300-\u036f]/g, '') + v = v.replace(/['’‘`]/g, '') + v = v.replace(/[^a-z0-9]+/g, '-') + v = v.replace(/^-+|-+$/g, '') + v = v.slice(0, 63) + return v.replace(/-+$/g, '') +} + export function suggestSubdomain(customerName, projectName) { const base = (customerName || '').trim() || (projectName || '').trim() - if (!base) return '' - - let value = base.toLowerCase() - value = value.replace(/[äöüß]/g, (ch) => UMLAUT_MAP[ch]) - value = value.normalize('NFD').replace(/[̀-ͯ]/g, '') - value = value.replace(/['’‘`]/g, '') - value = value.replace(/[^a-z0-9]+/g, '-') - value = value.replace(/^-+|-+$/g, '') - value = value.slice(0, 63) - value = value.replace(/-+$/g, '') - - return value + return slugify(base) } diff --git a/src/lib/throttleRuns.js b/src/lib/throttleRuns.js new file mode 100644 index 0000000..70b03bd --- /dev/null +++ b/src/lib/throttleRuns.js @@ -0,0 +1,17 @@ +// Ein Aufruf hoechstens alle `maxAgeMs`: ein laufender Aufruf wird geteilt, danach liefert die +// Funktion bis zum Ablauf null (uebersprungen). `now` nur fuer Tests. +export function throttleRuns(run, maxAgeMs) { + let pending = null + let doneAt = 0 + return ({ now = Date.now() } = {}) => { + if (pending) return pending + if (doneAt && now - doneAt < maxAgeMs) return Promise.resolve(null) + pending = Promise.resolve() + .then(run) + .finally(() => { + doneAt = Date.now() + pending = null + }) + return pending + } +} diff --git a/src/pages/AssignProjectPage.jsx b/src/pages/AssignProjectPage.jsx index 2128f03..9cf9f83 100644 --- a/src/pages/AssignProjectPage.jsx +++ b/src/pages/AssignProjectPage.jsx @@ -6,15 +6,13 @@ import { useAuth } from '../context/AuthContext' import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite' import { fetchServices, completeAssignment, fetchProject } from '../lib/projectKindsApi' import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from '../lib/assignFlow' -import { suggestSubdomain } from '../lib/subdomain' +import { suggestSubdomain, slugify } from '../lib/subdomain' import { isArchived } from '../lib/leads' import { PageHeader, Card, Button, Field } from '../components/ui' import { CreateCustomerForm } from './CustomersPage' const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) const fmtDate = (iso) => (iso ? new Date(iso).toLocaleDateString('de-DE') : '') -const slugify = (v) => - String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63) const STEP_TITLES = { kind: 'Was ist das?', @@ -95,11 +93,15 @@ export default function AssignProjectPage() { const openTickets = useMemo(() => openTicketsFor(tickets, state.customerId), [tickets, state.customerId]) const isWebsiteWithCustomer = state.kind === 'website' && state.forCustomer + // Hat das Projekt schon eine Website (z. B. ueber "Neues Projekt" mit Subdomain angelegt), bleibt + // sie wie sie ist: kein Vorschlag, kein erneutes Einschalten (das benannte sie sonst um und + // stellte eine oeffentliche Website auf Login-Preview). + const hasPreviewAlready = Boolean(project?.subdomain) useEffect(() => { - if (state.step === 'summary' && isWebsiteWithCustomer && !subdomain) { + if (state.step === 'summary' && isWebsiteWithCustomer && !hasPreviewAlready && !subdomain) { setSubdomain(suggestSubdomain('', repoName)) } - }, [state.step, isWebsiteWithCustomer, subdomain, repoName]) + }, [state.step, isWebsiteWithCustomer, hasPreviewAlready, subdomain, repoName]) const patch = (p) => setState((s) => ({ ...s, ...p })) const go = (step) => { setError(null); patch({ step }) } @@ -141,7 +143,7 @@ export default function AssignProjectPage() { const { topic, requestedBy, startDate, details } = state.newTicket body.newTicket = { topic, requestedBy, startDate, details } } - if (isWebsiteWithCustomer && previewOn && subdomain.trim()) { + if (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && subdomain.trim()) { body.preview = { subdomain: slugify(subdomain) } } } @@ -353,7 +355,12 @@ export default function AssignProjectPage() { )} - {isWebsiteWithCustomer && ( + {isWebsiteWithCustomer && hasPreviewAlready && ( +

+ Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert. +

+ )} + {isWebsiteWithCustomer && !hasPreviewAlready && (
)} - diff --git a/src/pages/ProjectsPage.jsx b/src/pages/ProjectsPage.jsx index fe22b8c..011a757 100644 --- a/src/pages/ProjectsPage.jsx +++ b/src/pages/ProjectsPage.jsx @@ -25,18 +25,12 @@ import { archiveWebsiteProject, unarchiveWebsiteProject, } from '../lib/projectAdminApi' -import { assignSummary } from '../lib/projectAssign' +import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign' +import { projectCounts, projectsInView } from '../lib/projectList' +import { slugify } from '../lib/subdomain' import PreviewLinkButton from '../components/PreviewLinkButton' import GiteaLinkButton from '../components/GiteaLinkButton' -function slugify(value) { - return String(value || '') - .toLowerCase() - .replace(/[^a-z0-9-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 100) -} - const WORKORDER_FILTERS = { limit: 500 } // Projekt-Typen (= "Template"/Hosting-Modus) @@ -91,7 +85,7 @@ function isProjectReady(project) { export default function ProjectsPage() { const navigate = useNavigate() - const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects() + const { projects, loading, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects() const { customers } = useCustomers() const { workorders } = useWorkorders(WORKORDER_FILTERS) const { employees } = useEmployees() @@ -158,29 +152,12 @@ export default function ProjectsPage() { [employees] ) - const counts = useMemo(() => { - // Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin) - const active = projects.filter((p) => !p.archived && !p.isTemplate) - return { - all: active.length, - unassigned: active.filter((p) => !p.customerId).length, - assigned: active.filter((p) => Boolean(p.customerId)).length, - archived: projects.filter((p) => p.archived).length, - templates: projects.filter((p) => p.isTemplate && !p.archived).length, - } - }, [projects]) + // Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin) + const counts = useMemo(() => projectCounts(projects, { isAdmin }), [projects, isAdmin]) const filteredProjects = useMemo(() => { // 1) Kategorie (Dropdown) - let base - if (filter === 'templates') base = projects.filter((p) => p.isTemplate && !p.archived) - else if (filter === 'archived') base = projects.filter((p) => p.archived) - else { - const active = projects.filter((p) => !p.archived && !p.isTemplate) - if (filter === 'unassigned') base = active.filter((p) => !p.customerId) - else if (filter === 'assigned') base = active.filter((p) => Boolean(p.customerId)) - else base = active - } + let base = projectsInView(projects, filter, { isAdmin }) // 2) Textsuche (Titel/Subdomain/Repo) const q = search.trim().toLowerCase() @@ -204,7 +181,7 @@ export default function ProjectsPage() { } return base - }, [projects, filter, search, customerFilter, employeeFilter, workorderById]) + }, [projects, filter, isAdmin, search, customerFilter, employeeFilter, workorderById]) const handleCreate = async (e) => { e.preventDefault() @@ -212,7 +189,7 @@ export default function ProjectsPage() { setCreateError('') try { const result = await createProjectFromTemplate({ - repoName: createForm.repoName || slugify(createForm.subdomain) || slugify(createForm.displayName), + repoName: slugify(createForm.repoName) || slugify(createForm.subdomain) || slugify(createForm.displayName), displayName: createForm.displayName, subdomain: slugify(createForm.subdomain), customerId: createForm.customerId || '', @@ -230,6 +207,8 @@ export default function ProjectsPage() { if (result.projectId) navigate(`/projects/zuordnen/${result.projectId}`) } catch (err) { setCreateError(err.message || 'Projekt konnte nicht angelegt werden') + // Das Repo kann trotz Fehler entstanden sein - Liste trotzdem neu laden + fetchAllProjects() } finally { setCreateLoading(false) } @@ -253,13 +232,14 @@ export default function ProjectsPage() { setAssignBusyId(project.$id) setNotice('') if (customerId) { - const r = await assignProjects([project.$id], { customerId, ticketId: project.ticketId || '' }) + const ticketId = ticketIdForCustomer(project, customerId, workorderById) + const r = await assignProjects([project.$id], { customerId, ticketId }) const one = r.results[0] const summary = assignSummary(project.projectName, one.ok ? one.result : { error: one.error }) setNotice(summary.warnings.length ? `${summary.text} – ${summary.warnings.join(' – ')}` : summary.text) - } else { - const r = await unassignProject(project.$id) - if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt')) + } else if (window.confirm(`Kunde von "${project.projectName}" entfernen? Der Kunde sieht das Projekt dann nicht mehr im Portal, das Ticket wird gelöst.`)) { + const r = await clearCustomer(project.$id) + setNotice(r.success ? `"${project.projectName}" hat keinen Kunden mehr.` : 'Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt')) } await fetchAllProjects() setAssignBusyId('') @@ -366,12 +346,13 @@ export default function ProjectsPage() { } const FILTER_OPTIONS = [ - { value: 'all', label: `Alle Projekte (${counts.all})` }, + { value: 'all', label: `Aktive Projekte (${counts.all})` }, { value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` }, { value: 'assigned', label: `Zugeordnet (${counts.assigned})` }, // Vorlagen-Ansicht nur fuer Admins ...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []), { value: 'archived', label: `Archiviert (${counts.archived})` }, + { value: 'everything', label: `Alle inkl. Archiv (${counts.everything})` }, ] // Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins @@ -627,13 +608,9 @@ export default function ProjectsPage() { type="text" className="form-control" value={createForm.subdomain} - onChange={(e) => - setCreateForm((p) => ({ - ...p, - subdomain: e.target.value, - repoName: p.repoName || slugify(e.target.value), - })) - } + // Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst + // stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name. + onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))} placeholder="leer lassen = Projekt ohne Website" /> @@ -747,12 +724,23 @@ export default function ProjectsPage() { ? <> Reaktivieren : <> Repository archivieren} - + {editProject.archived && ( + + Archiviert: zum Bearbeiten oder Hosten erst reaktivieren. + + )} - {editForm.projectType !== 'project' && ( + {/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */} + {editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
diff --git a/tests/unit/importThrottle.test.mjs b/tests/unit/importThrottle.test.mjs new file mode 100644 index 0000000..70f25bf --- /dev/null +++ b/tests/unit/importThrottle.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { throttleRuns } = await loadModule('src/lib/throttleRuns.js') + +test('Repo-Import hoechstens einmal pro Minute, laufender Abgleich wird geteilt', async () => { + let runs = 0 + let release + const throttled = throttleRuns(() => { runs += 1; return new Promise((r) => { release = r }) }, 60000) + const a = throttled() + const b = throttled() + assert.equal(a, b) + await new Promise((r) => setTimeout(r, 0)) + release({ created: 0 }) + await a + assert.equal(runs, 1) + assert.equal(await throttled(), null) + assert.equal(runs, 1) + const later = throttled({ now: Date.now() + 61000 }) + await new Promise((r) => setTimeout(r, 0)) + release({ created: 1 }) + assert.deepEqual(await later, { created: 1 }) + assert.equal(runs, 2) +}) + +test('Fehler wird weitergereicht, der naechste Versuch wartet trotzdem die Minute ab', async () => { + const throttled = throttleRuns(async () => { throw new Error('weg') }, 60000) + await assert.rejects(throttled(), /weg/) + assert.equal(await throttled(), null) +}) diff --git a/tests/unit/projectAssign.test.mjs b/tests/unit/projectAssign.test.mjs index 7a3dfe7..489d23f 100644 --- a/tests/unit/projectAssign.test.mjs +++ b/tests/unit/projectAssign.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { loadModule } from './load.mjs' -const { isOutsideOrg, needsPreviewButton, assignSummary, assignSequentially } = await loadModule( +const { isOutsideOrg, needsPreviewButton, assignSummary, assignSequentially, assignableProjects, ticketIdForCustomer } = await loadModule( 'src/lib/projectAssign.js' ) @@ -17,9 +17,36 @@ test('isOutsideOrg', () => { }) test('needsPreviewButton', () => { - assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'deployed' }), false) + assert.equal(needsPreviewButton({ subdomain: 'x', previewUrl: 'https://x', status: 'deployed' }), false) assert.equal(needsPreviewButton({ previewUrl: '', status: 'synced' }), true) - assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'pending' }), true) + // gerade angelegt, der Runner baut noch (status noch synced/pending): kein "Preview einschalten" + assert.equal(needsPreviewButton({ subdomain: 'x', previewUrl: 'https://x', status: 'synced' }), false) + assert.equal(needsPreviewButton({ subdomain: 'x', previewUrl: 'https://x', status: 'pending' }), false) + // archiviert: erst reaktivieren + assert.equal(needsPreviewButton({ archived: true, status: 'archived' }), false) +}) + +test('assignableProjects: Flag archived zaehlt, nicht status', () => { + const all = [ + { $id: 'a', repoFullName: 'WEBklar/a', status: 'archived', archived: false }, // reaktiviert, alter status + { $id: 'b', repoFullName: 'JUSN/b', status: 'synced', archived: true }, // in Gitea archiviert + { $id: 'c', repoFullName: 'WEBklar/c', customerId: 'k2' }, // anderer Kunde + { $id: 'd', repoFullName: 'WEBklar/d', customerId: 'k1' }, + { $id: 'e', repoFullName: 'WEBklar/e', isTemplate: true }, + { $id: 'f', projectName: 'ohne Repo' }, + { $id: 'g', repoFullName: 'WEBklar/g' }, + ] + const ids = assignableProjects(all, { customerId: 'k1' }, new Set(['g'])).map((p) => p.$id) + assert.deepEqual(ids, ['a', 'd']) +}) + +test('ticketIdForCustomer: altes Ticket nur beim gleichen Kunden', () => { + const p = { customerId: 'A', ticketId: 'tA' } + const wo = { tA: { customerId: 'A' }, tB: { customerId: 'B' } } + assert.equal(ticketIdForCustomer(p, 'A', wo), 'tA') + assert.equal(ticketIdForCustomer(p, 'B', wo), '') + assert.equal(ticketIdForCustomer({ customerId: 'A', ticketId: 'tB' }, 'B', wo), 'tB') + assert.equal(ticketIdForCustomer({ customerId: '', ticketId: '' }, 'B', wo), '') }) test('assignSummary', () => { diff --git a/tests/unit/projectList.test.mjs b/tests/unit/projectList.test.mjs new file mode 100644 index 0000000..275d693 --- /dev/null +++ b/tests/unit/projectList.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { projectCounts, projectsInView } = await loadModule('src/lib/projectList.js') + +const P = [ + { $id: 'a' }, + { $id: 'b', customerId: 'k' }, + { $id: 'c', archived: true }, + { $id: 'd', isTemplate: true }, + { $id: 'e', isTemplate: true, archived: true }, +] +const ids = (list) => list.map((p) => p.$id) + +test('Aktive Projekte ohne Archiv und Vorlagen', () => { + assert.deepEqual(ids(projectsInView(P, 'all')), ['a', 'b']) + assert.deepEqual(ids(projectsInView(P, 'unassigned')), ['a']) + assert.deepEqual(ids(projectsInView(P, 'assigned')), ['b']) +}) + +test('Alle inkl. Archiv: wirklich alles (Vorlagen nur fuer Admins)', () => { + assert.deepEqual(ids(projectsInView(P, 'everything')), ['a', 'b', 'c']) + assert.deepEqual(ids(projectsInView(P, 'everything', { isAdmin: true })), ['a', 'b', 'c', 'd', 'e']) +}) + +test('Archiviert und Vorlagen', () => { + assert.deepEqual(ids(projectsInView(P, 'archived')), ['c']) + assert.deepEqual(ids(projectsInView(P, 'archived', { isAdmin: true })), ['c', 'e']) + assert.deepEqual(ids(projectsInView(P, 'templates')), ['d']) +}) + +test('Zaehler passen zu den Ansichten', () => { + assert.deepEqual(projectCounts(P), { all: 2, unassigned: 1, assigned: 1, archived: 1, templates: 1, everything: 3 }) + assert.deepEqual(projectCounts(P, { isAdmin: true }).everything, 5) +}) diff --git a/tests/unit/subdomain.test.mjs b/tests/unit/subdomain.test.mjs index bff1f6d..4daaab0 100644 --- a/tests/unit/subdomain.test.mjs +++ b/tests/unit/subdomain.test.mjs @@ -2,7 +2,14 @@ import test from 'node:test' import assert from 'node:assert/strict' import { loadModule } from './load.mjs' -const { suggestSubdomain } = await loadModule('src/lib/subdomain.js') +const { suggestSubdomain, slugify } = await loadModule('src/lib/subdomain.js') + +test('slugify schreibt Umlaute aus wie das Portal', () => { + assert.equal(slugify('Müller & Söhne'), 'mueller-soehne') + assert.equal(slugify(' ZZ Test Ablauf '), 'zz-test-ablauf') + assert.equal(slugify('x'.repeat(70)), 'x'.repeat(63)) + assert.equal(slugify(null), '') +}) test('Kundenname vor Projektname', () => { assert.equal(suggestSubdomain('Döner Nation', 'D-ner-Nation'), 'doener-nation')