#!/usr/bin/env node const baseUrl = process.argv[2] ?? process.env.API_BASE_URL ?? "http://127.0.0.1:8080"; const email = `admin+${Date.now()}@example.com`; async function request(method, path, body, token, options = {}) { const response = await fetch(`${baseUrl}${path}`, { method, headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await response.text(); const data = text ? parseResponseBody(text) : {}; const expectedStatus = options.expectedStatus; if (expectedStatus !== undefined) { if (response.status !== expectedStatus) { throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(data)}`); } return data; } if (!response.ok) { throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(data)}`); } return data; } function parseResponseBody(text) { try { return JSON.parse(text); } catch { return { message: text }; } } function assert(condition, message) { if (!condition) throw new Error(message); } async function main() { console.log(`testing onboarding api via ${baseUrl}`); const registration = await request("POST", "/api/v1/registration/organization", { organization_name: "Muster GmbH", email, accept_terms: true, }); assert(registration.id, "registration id missing"); console.log(`registered ${registration.id}`); const registrations = await request("GET", "/api/v1/admin/organization-registrations"); assert( registrations.some((item) => item.id === registration.id), "registration not found in list", ); const detail = await request( "GET", `/api/v1/admin/organization-registrations/${registration.id}`, ); assert(detail.organization_name === "Muster GmbH", "organization name did not decrypt"); assert(detail.email === email, "registration email mismatch"); const approval = await request( "POST", `/api/v1/admin/organization-registrations/${registration.id}/approve`, ); assert(approval.organization_id, "organization id missing"); assert(approval.schema_name?.startsWith("company_"), "schema name missing"); assert(approval.dev_initial_password, "dev initial password missing"); console.log(`approved ${approval.organization_id}`); const login = await request("POST", "/api/v1/auth/login", { email, password: approval.dev_initial_password, }); assert(login.must_change_password === true, "must_change_password should be true"); assert(login.access_token, "access token missing"); assert(login.organization_id, "selected organization missing"); assert(login.organizations.length >= 1, "login organizations missing"); const token = login.access_token; const selected = await request( "POST", "/api/v1/auth/select-organization", { organization_id: login.organization_id }, token, ); assert(selected.selected === true, "organization was not selected"); const users = await request("GET", "/api/v1/organizations/current/users", undefined, token); assert(users.length >= 1, "users list is empty"); assert(users.some((user) => user.email === email), "owner user missing"); const setup = await request("PUT", "/api/v1/organizations/current/setup", { display_name: "Muster GmbH", legal_form: "GmbH", street: "Musterstrasse 1", postal_code: "12345", city: "Musterstadt", country: "Deutschland", vat_id: "", email: "info@example.com", phone: "", default_tax_rate: "19", default_payment_days: "14", }, token); assert(setup.saved === true, "organization setup was not saved"); const loadedSetup = await request("GET", "/api/v1/organizations/current/setup", undefined, token); assert(loadedSetup.setup?.display_name === "Muster GmbH", "organization setup was not loaded"); assert(loadedSetup.setup?.street === "Musterstrasse 1", "organization setup street mismatch"); const numberRanges = await request("GET", "/api/v1/number-ranges", undefined, token); assert(numberRanges.some((range) => range.code === "customers" && range.pattern === "KU{counter}"), "customer number range missing"); assert(numberRanges.some((range) => range.code === "items" && range.pattern === "AR{counter}"), "item number range missing"); assert(numberRanges.some((range) => range.code === "activities" && range.pattern === "AK{counter}"), "activity number range missing"); assert(numberRanges.some((range) => range.code === "outgoing_invoices" && range.pattern === "AR{counter}"), "invoice number range missing"); const cashDiscountTerm = await request("POST", "/api/v1/cash-discount-terms", { code: "2-10-30", name: "2 % Skonto bei Zahlung innerhalb von 10 Tagen", discount_percent: "2.00", discount_days: 10, net_days: 30, valid_from: null, valid_until: null, is_default_customer_term: true, is_default_supplier_term: true, is_active: true, }, token); assert(cashDiscountTerm.id, "cash discount term id missing"); const cashDiscountTerms = await request("GET", "/api/v1/cash-discount-terms", undefined, token); assert(cashDiscountTerms.some((term) => term.id === cashDiscountTerm.id), "cash discount term missing"); const createdCustomer = await request("POST", "/api/v1/customers", { customer_number: "", name: "Beispielkunde GmbH", status: "active", details: { street: "Kundenstraße 4", postal_code: "54321", city: "Kundenstadt", country: "Deutschland", email: "kunde@example.com", phone: "01234 56789", }, standard_discount_percent: "5.50", cash_discount_term_id: cashDiscountTerm.id, }, token); assert(createdCustomer.id, "customer id missing"); assert(/^KU\d{3}\.\d{3}\.\d{3}$/.test(createdCustomer.customer_number), "customer number was not generated"); const customers = await request("GET", "/api/v1/customers", undefined, token); const listedCustomer = customers.find((customer) => customer.id === createdCustomer.id); assert(listedCustomer?.name === "Beispielkunde GmbH", "customer name was not loaded"); assert(listedCustomer?.details.city === "Kundenstadt", "customer details were not loaded"); assert(listedCustomer?.standard_discount_percent.startsWith("5.5"), "customer discount missing"); assert(listedCustomer?.cash_discount_term_id === cashDiscountTerm.id, "customer cash discount missing"); const updatedCustomer = await request("PUT", `/api/v1/customers/${createdCustomer.id}`, { ...createdCustomer, name: "Beispielkunde AG", standard_discount_percent: "7.00", }, token); assert(updatedCustomer.name === "Beispielkunde AG", "customer update failed"); const supplier = await request("POST", "/api/v1/suppliers", { supplier_number: "", name: "Beispiellieferant GmbH", status: "active", details: { street: "Lieferweg 1", postal_code: "10115", city: "Berlin", country: "Deutschland", email: "lieferant@example.com", phone: "" }, standard_discount_percent: "2.00", cash_discount_term_id: cashDiscountTerm.id, payment_days: 30, }, token); assert(/^LI\d{3}\.\d{3}\.\d{3}$/.test(supplier.supplier_number), "supplier number was not generated"); const suppliers = await request("GET", "/api/v1/suppliers", undefined, token); assert(suppliers.some((record) => record.id === supplier.id && record.details.city === "Berlin"), "supplier CRUD failed"); assert(suppliers.some((record) => record.id === supplier.id && record.cash_discount_term_id === cashDiscountTerm.id), "supplier cash discount missing"); const item = await request("POST", "/api/v1/items", { item_number: "", name: "Montagestunde", unit: "Std", tax_rate: "19", default_purchase_price: "40.00", default_sales_price: "85.00", status: "active", }, token); assert(/^AR\d{3}\.\d{3}\.\d{3}$/.test(item.item_number), "item number was not generated"); const updatedItem = await request("PUT", `/api/v1/items/${item.id}`, { ...item, default_sales_price: "95.00" }, token); assert(updatedItem.default_sales_price === "95.00", "item update failed"); const priceHistory = await request("GET", `/api/v1/items/${item.id}/prices`, undefined, token); assert(priceHistory.length >= 2, "item price history missing"); assert(priceHistory.some((entry) => entry.sales_price?.startsWith("95")), "updated item price history missing"); const priceListContent = [ "item_number;name;unit;tax_rate;purchase_price;sales_price", "IMP-100;Importartikel;Stk;19;10.00;25.00", `${item.item_number};Montagestunde Import;Std;19;42.00;99.00`, ].join("\n"); const importPreview = await request("POST", "/api/v1/imports/price-list/preview", { source_name: "api-test-price-list.csv", delimiter: ";", content: priceListContent, }, token); assert(importPreview.total_rows === 2, "price import preview row count mismatch"); assert(importPreview.valid_rows === 2, "price import preview valid rows mismatch"); assert(importPreview.rows.some((row) => row.item_number === "IMP-100" && row.action === "create"), "price import create action missing"); assert(importPreview.rows.some((row) => row.item_number === item.item_number && row.action === "update"), "price import update action missing"); const importApply = await request("POST", "/api/v1/imports/price-list/apply", { source_name: "api-test-price-list.csv", delimiter: ";", content: priceListContent, }, token); assert(importApply.import_id, "price import id missing"); assert(importApply.applied_rows === 2, "price import applied rows mismatch"); assert(importApply.error_rows === 0, "price import errors mismatch"); const importedItems = await request("GET", "/api/v1/items", undefined, token); assert(importedItems.some((record) => record.item_number === "IMP-100" && record.name === "Importartikel"), "imported item missing"); const reloadedItem = importedItems.find((record) => record.id === item.id); assert(reloadedItem?.default_sales_price?.startsWith("99"), "imported item price update missing"); const importedPriceHistory = await request("GET", `/api/v1/items/${item.id}/prices`, undefined, token); assert(importedPriceHistory.some((entry) => entry.source.startsWith("import:") && entry.sales_price?.startsWith("99")), "import price history missing"); const connector = await request("POST", "/api/v1/api-connectors", { code: "demo_price_api", name: "Demo Preis API", connector_type: "demo", config: { base_url: "https://example.test/api", token: "secret", delimiter: ";", price_list_csv: [ "item_number;name;unit;tax_rate;purchase_price;sales_price", "IMP-100;Importartikel API;Stk;19;11.00;27.00", ].join("\n"), }, is_active: true, sync_interval_minutes: 60, }, token); assert(connector.id, "api connector id missing"); assert(connector.config?.token === "secret", "api connector config roundtrip failed"); const connectors = await request("GET", "/api/v1/api-connectors", undefined, token); assert(connectors.some((record) => record.id === connector.id && record.config?.base_url === "https://example.test/api"), "api connector list missing"); const connectorSync = await request("POST", `/api/v1/api-connectors/${connector.id}/sync`, {}, token); assert(connectorSync.synced === true, "api connector sync failed"); assert(connectorSync.applied_rows === 1, "api connector price sync did not apply rows"); const syncedItems = await request("GET", "/api/v1/items", undefined, token); assert(syncedItems.some((record) => record.item_number === "IMP-100" && record.default_sales_price?.startsWith("27")), "api connector price update missing"); const deletedConnector = await request("DELETE", `/api/v1/api-connectors/${connector.id}`, undefined, token); assert(deletedConnector.deleted === true, "api connector deactivate failed"); const priceRule = await request("POST", "/api/v1/price-rules", { code: "standard_import_markup", name: "Standardaufschlag Import", source_type: "import", source_id: null, markup_percent: "25.00", rounding_mode: "cent", is_active: true, }, token); assert(priceRule.id, "price rule id missing"); const priceRules = await request("GET", "/api/v1/price-rules", undefined, token); assert(priceRules.some((record) => record.id === priceRule.id && record.markup_percent.startsWith("25")), "price rule list missing"); const updatedPriceRule = await request("PUT", `/api/v1/price-rules/${priceRule.id}`, { ...priceRule, markup_percent: "30.00", rounding_mode: "five_cent", }, token); assert(updatedPriceRule.rounding_mode === "five_cent", "price rule update failed"); const deletedPriceRule = await request("DELETE", `/api/v1/price-rules/${priceRule.id}`, undefined, token); assert(deletedPriceRule.deleted === true, "price rule deactivate failed"); const quote = await request("POST", "/api/v1/quotes", { quote_number: "", customer_id: createdCustomer.id, status: "draft", valid_until: null, cash_discount_term_id: cashDiscountTerm.id, customer_discount_percent: "7.00", notes: "Erstes Testangebot.", items: [{ item_id: item.id, description: "Montagestunde mit Sonderpreis", quantity: "2.00", unit_price: "90.00", original_unit_price: "95.00", discount_percent: "0.00", tax_rate: "19.00", }], }, token); assert(/^AN\d{3}\.\d{3}\.\d{3}$/.test(quote.quote_number), "quote number was not generated"); assert(quote.items.length === 1, "quote item missing"); assert(quote.items[0].price_overridden === true, "quote price override missing"); const quotes = await request("GET", "/api/v1/quotes", undefined, token); assert(quotes.some((record) => record.id === quote.id && record.notes.includes("Testangebot")), "quote was not loaded"); const updatedQuote = await request("PUT", `/api/v1/quotes/${quote.id}`, { ...quote, status: "sent", items: quote.items.map((line) => ({ ...line, quantity: "3.00" })), }, token); assert(updatedQuote.status === "sent", "quote update failed"); const convertedInvoice = await request("POST", `/api/v1/quotes/${quote.id}/convert-to-invoice`, undefined, token); assert(/^AR\d{3}\.\d{3}\.\d{3}$/.test(convertedInvoice.invoice_number), "converted invoice number missing"); assert(convertedInvoice.source_quote_id === quote.id, "converted invoice quote link missing"); assert(convertedInvoice.items.length === 1, "converted invoice item missing"); const invoices = await request("GET", "/api/v1/outgoing-invoices", undefined, token); assert(invoices.some((record) => record.id === convertedInvoice.id), "outgoing invoice was not loaded"); const finalized = await request("POST", `/api/v1/outgoing-invoices/${convertedInvoice.id}/finalize`, undefined, token); assert(finalized.finalized === true, "outgoing invoice finalize failed"); const deletedQuote = await request("DELETE", `/api/v1/quotes/${quote.id}`, undefined, token); assert(deletedQuote.deleted === true, "quote cancel failed"); const incomingInvoice = await request("POST", "/api/v1/incoming-invoices", { invoice_number: "EXT-10001", supplier_id: supplier.id, status: "received", cash_discount_term_id: cashDiscountTerm.id, invoice_date: null, due_at: null, items: [{ item_id: item.id, description: "Einkauf Montagestunde", quantity: "1.00", unit_price: "40.00", tax_rate: "19.00", }], }, token); assert(incomingInvoice.id, "incoming invoice id missing"); assert(incomingInvoice.cash_discount_term_id === cashDiscountTerm.id, "incoming invoice cash discount missing"); const incomingInvoices = await request("GET", "/api/v1/incoming-invoices", undefined, token); assert(incomingInvoices.some((record) => record.id === incomingInvoice.id), "incoming invoice was not loaded"); const deletedIncomingInvoice = await request("DELETE", `/api/v1/incoming-invoices/${incomingInvoice.id}`, undefined, token); assert(deletedIncomingInvoice.deleted === true, "incoming invoice cancel failed"); const deletedItem = await request("DELETE", `/api/v1/items/${item.id}`, undefined, token); assert(deletedItem.deleted === true, "item deactivate failed"); const deletedSupplier = await request("DELETE", `/api/v1/suppliers/${supplier.id}`, undefined, token); assert(deletedSupplier.deleted === true, "supplier deactivate failed"); const deletedCustomer = await request("DELETE", `/api/v1/customers/${createdCustomer.id}`, undefined, token); assert(deletedCustomer.deleted === true, "customer deactivate failed"); const activity = await request("POST", "/api/v1/activities", { activity_number: null, activity_type: "task", title: "Angebot prüfen", body: "Preise mit Kunde abstimmen.", status: "open", priority: "high", due_at: null, }, token); assert(/^AK\d{3}\.\d{3}\.\d{3}$/.test(activity.activity_number), "activity number was not generated"); const activities = await request("GET", "/api/v1/activities", undefined, token); assert(activities.some((record) => record.id === activity.id && record.body.includes("Preise")), "activity CRUD failed"); const deletedActivity = await request("DELETE", `/api/v1/activities/${activity.id}`, undefined, token); assert(deletedActivity.deleted === true, "activity cancel failed"); const communication = await request("POST", "/api/v1/communications", { communication_type: "email", direction: "outbound", subject: "Rückfrage zum Angebot", body: "Kunde bittet um aktualisierte Dokumente.", status: "open", occurred_at: null, links: [{ entity_type: "customer", entity_id: createdCustomer.id }], }, token); assert(communication.id, "communication id missing"); assert(communication.subject === "Rückfrage zum Angebot", "communication subject mismatch"); assert(communication.links.some((link) => link.entity_id === createdCustomer.id), "communication link missing"); const communications = await request("GET", "/api/v1/communications", undefined, token); assert(communications.some((record) => record.id === communication.id), "communication list missing"); const documentContent = Buffer.from("Dokumentinhalt für Phase 6", "utf8").toString("base64"); const documentRecord = await request("POST", "/api/v1/documents", { title: "Testdokument", description: "Dokument für Phase 6", file_name: "phase-6.txt", content_type: "text/plain", content_base64: documentContent, links: [{ entity_type: "communication", entity_id: communication.id }], }, token); assert(documentRecord.id, "document id missing"); assert(documentRecord.latest_version?.file_name === "phase-6.txt", "document metadata missing"); const documents = await request("GET", "/api/v1/documents", undefined, token); assert(documents.some((record) => record.id === documentRecord.id), "document list missing"); const downloadedDocument = await request("GET", `/api/v1/documents/${documentRecord.id}/download`, undefined, token); assert(downloadedDocument.content_base64 === documentContent, "document download content mismatch"); const auditLog = await request("GET", `/api/v1/documents/${documentRecord.id}/audit-log`, undefined, token); assert(auditLog.some((entry) => entry.action === "upload"), "document upload audit missing"); assert(auditLog.some((entry) => entry.action === "download"), "document download audit missing"); const deletedDocument = await request("DELETE", `/api/v1/documents/${documentRecord.id}`, undefined, token); assert(deletedDocument.deleted === true, "document archive failed"); const invitedEmail = `user+${Date.now()}@example.com`; const invitation = await request("POST", "/api/v1/organizations/current/invitations", { email: invitedEmail, roles: ["viewer"], }, token); assert(invitation.id, "invitation id missing"); assert(invitation.dev_invitation_token, "dev invitation token missing"); const invitedUsers = await request("GET", "/api/v1/organizations/current/users", undefined, token); const invitedUser = invitedUsers.find((user) => user.user_id === invitation.user_id); assert(invitedUser, "invited user missing"); const acceptedInvitation = await request("POST", "/api/v1/auth/accept-invitation", { token: invitation.dev_invitation_token, new_password: "InvitePass123", new_password_confirm: "InvitePass123", }); assert(acceptedInvitation.accepted === true, "invitation accept failed"); const invitedLogin = await request("POST", "/api/v1/auth/login", { email: invitedEmail, password: "InvitePass123", }); assert(invitedLogin.access_token, "invited user login failed"); assert(invitedLogin.organization_id, "invited user selected organization missing"); const invitedToken = invitedLogin.access_token; await request( "POST", "/api/v1/auth/select-organization", { organization_id: invitedLogin.organization_id }, invitedToken, ); const deniedCustomerWrite = await request("POST", "/api/v1/customers", { customer_number: "", name: "Nicht erlaubt GmbH", status: "active", details: { street: "Sperrweg 1", postal_code: "12345", city: "Teststadt", country: "Deutschland", email: "nicht-erlaubt@example.test", phone: "", }, standard_discount_percent: "0", cash_discount_term_id: null, }, invitedToken, { expectedStatus: 403 }); assert(deniedCustomerWrite.message === "Berechtigung fehlt", "viewer customer write was not forbidden"); const deniedRoleWrite = await request( "PATCH", `/api/v1/organizations/current/users/${invitation.user_id}/roles`, { roles: ["admin"] }, invitedToken, { expectedStatus: 403 }, ); assert(deniedRoleWrite.message === "Berechtigung fehlt", "viewer role write was not forbidden"); await request( "PATCH", `/api/v1/organizations/current/users/${invitation.user_id}/roles`, { roles: ["sales", "viewer"] }, token, ); const updatedUsers = await request("GET", "/api/v1/organizations/current/users", undefined, token); const updatedUser = updatedUsers.find((user) => user.user_id === invitation.user_id); assert(updatedUser.roles.includes("sales"), "role change was not saved"); const resetRequest = await request("POST", "/api/v1/auth/request-password-reset", { email }); assert(resetRequest.queued === true, "password reset request failed"); assert(resetRequest.dev_reset_token, "dev reset token missing"); const resetPassword = await request("POST", "/api/v1/auth/reset-password", { token: resetRequest.dev_reset_token, new_password: "ResetPass123", new_password_confirm: "ResetPass123", }); assert(resetPassword.changed === true, "password reset failed"); const resetLogin = await request("POST", "/api/v1/auth/login", { email, password: "ResetPass123", }); assert(resetLogin.access_token, "login after password reset failed"); console.log("onboarding api test ok"); } main().catch((error) => { console.error(error); process.exit(1); });