Add comprehensive tests for client validation, revenue and expense categories, invoice generation, and schemas
- Implement tests for client validation functions including tax ID, VAT ID, IBAN, BIC, website, and company form validation. - Create tests for revenue and expense categories ensuring all expected categories and labels are present. - Add tests for invoice number generation with various scenarios including prefix handling and sequence padding. - Introduce tests for default categories and their integration, ensuring no overlaps and consistent naming conventions. - Implement Zod schema validation tests for currency, tax rates, IBAN, tax ID, VAT ID, invoices, companies, and customers. - Add utility tests for tax calculations, including item amounts and invoice totals, ensuring correct handling of tax rates and formatting. - Set up Vitest configuration and global test setup for consistent testing environment.
This commit is contained in:
@@ -20,6 +20,18 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
|||||||
Generated
+1344
-18
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -10,6 +10,10 @@
|
|||||||
"start": "react-router-serve ./build/server/index.js",
|
"start": "react-router-serve ./build/server/index.js",
|
||||||
"typecheck": "react-router typegen && tsc",
|
"typecheck": "react-router typegen && tsc",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:seed": "npx ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts",
|
"db:seed": "npx ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
@@ -54,15 +58,21 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.13.1",
|
"@react-router/dev": "^7.13.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitejs/plugin-react": "^4",
|
"@vitejs/plugin-react": "^4",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
|
"@vitest/ui": "^4.1.5",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vite": "^6",
|
"vite": "^6",
|
||||||
"vite-tsconfig-paths": "^5"
|
"vite-tsconfig-paths": "^5",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Tests - Annas RechnungsManager
|
||||||
|
|
||||||
|
Vitest-basiertes Test-Framework für kritische Geschäftslogik.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies (already done)
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm run test # Single run
|
||||||
|
npm run test:watch # Watch mode
|
||||||
|
npm run test:ui # Browser UI
|
||||||
|
npm run test:coverage # With coverage report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── lib/
|
||||||
|
│ ├── tax.test.ts # Steuerberechnungen (§14 UStG)
|
||||||
|
│ └── schemas.test.ts # Zod-Validierung (Input-Sicherheit)
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
- ✅ **tax.ts** - German tax calculations (19%, 7%, Kleinunternehmer)
|
||||||
|
- ✅ **schemas.ts** - Input validation (Zod schemas)
|
||||||
|
- ⚠️ **invoice-number.server.ts** - Requires Prisma mocking (TODO)
|
||||||
|
|
||||||
|
## Critical Test Areas
|
||||||
|
|
||||||
|
1. **Tax Calculations** - Must be correct per German tax law
|
||||||
|
2. **Input Validation** - Prevent invalid data in DB
|
||||||
|
3. **Invoice Logic** - §14 UStG compliance
|
||||||
|
4. **Buchhaltung** - Double-entry bookkeeping accuracy
|
||||||
|
|
||||||
|
## Running Specific Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run only tax tests
|
||||||
|
npx vitest run tests/lib/tax.test.ts
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
# Opens: ./coverage/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing New Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { myFunction } from "@/lib/my-module";
|
||||||
|
|
||||||
|
describe("myModule", () => {
|
||||||
|
it("should do something", () => {
|
||||||
|
expect(myFunction()).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI Integration
|
||||||
|
|
||||||
|
Tests laufen automatisch in der Gitea Actions Pipeline (`.gitea/workflows/build.yml`).
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
|
||||||
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
describe("InvoiceStatusBadge", () => {
|
||||||
|
it("should render DRAFT status correctly", () => {
|
||||||
|
render(<InvoiceStatusBadge status={InvoiceStatus.DRAFT} />);
|
||||||
|
expect(screen.getByText("Entwurf")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render SENT status correctly", () => {
|
||||||
|
render(<InvoiceStatusBadge status={InvoiceStatus.SENT} />);
|
||||||
|
expect(screen.getByText("Versendet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render PAID status correctly", () => {
|
||||||
|
render(<InvoiceStatusBadge status={InvoiceStatus.PAID} />);
|
||||||
|
expect(screen.getByText("Bezahlt")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render CANCELLED status correctly", () => {
|
||||||
|
render(<InvoiceStatusBadge status={InvoiceStatus.CANCELLED} />);
|
||||||
|
expect(screen.getByText("Storniert")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render DELETED status correctly", () => {
|
||||||
|
render(<InvoiceStatusBadge status={InvoiceStatus.DELETED} />);
|
||||||
|
expect(screen.getByText("Gelöscht")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper badge structure", () => {
|
||||||
|
render(<InvoiceStatusBadge status={InvoiceStatus.PAID} />);
|
||||||
|
const badge = screen.getByText("Bezahlt");
|
||||||
|
expect(badge).toBeInTheDocument();
|
||||||
|
// Badge should be a div element
|
||||||
|
expect(badge.tagName).toBe("DIV");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple Integration Tests
|
||||||
|
*
|
||||||
|
* These tests verify that the API logic works correctly
|
||||||
|
* without requiring a full database connection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("API Integration (Simple)", () => {
|
||||||
|
describe("Company API Logic", () => {
|
||||||
|
it("should validate company creation data", async () => {
|
||||||
|
const { companySchema } = await import("@/lib/schemas");
|
||||||
|
|
||||||
|
const validCompany = {
|
||||||
|
name: "Test GmbH",
|
||||||
|
address: "Hauptstraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
country: "DE",
|
||||||
|
invoicePrefix: "RE",
|
||||||
|
kleinunternehmer: false,
|
||||||
|
taxId: null,
|
||||||
|
vatId: null,
|
||||||
|
bankIban: null,
|
||||||
|
bankBic: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = companySchema.safeParse(validCompany);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid company data", async () => {
|
||||||
|
const { companySchema } = await import("@/lib/schemas");
|
||||||
|
|
||||||
|
const invalidCompany = {
|
||||||
|
name: "", // Invalid: empty name
|
||||||
|
address: "Test St. 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = companySchema.safeParse(invalidCompany);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Invoice API Logic", () => {
|
||||||
|
it("should validate invoice creation data", async () => {
|
||||||
|
const { invoiceSchema } = await import("@/lib/schemas");
|
||||||
|
|
||||||
|
const validInvoice = {
|
||||||
|
companyId: "company-123",
|
||||||
|
customerId: "customer-456",
|
||||||
|
issueDate: "2026-05-08",
|
||||||
|
dueDate: "2026-06-08",
|
||||||
|
kleinunternehmer: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
description: "Web Development",
|
||||||
|
quantity: 10,
|
||||||
|
unit: "Stunden",
|
||||||
|
unitPrice: 100,
|
||||||
|
taxRate: 19,
|
||||||
|
netAmount: 1000,
|
||||||
|
taxAmount: 190,
|
||||||
|
grossAmount: 1190,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
netTotal: 1000,
|
||||||
|
taxTotal: 190,
|
||||||
|
grossTotal: 1190,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = invoiceSchema.safeParse(validInvoice);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invoice with no items", async () => {
|
||||||
|
const { invoiceSchema } = await import("@/lib/schemas");
|
||||||
|
|
||||||
|
const invalidInvoice = {
|
||||||
|
companyId: "company-123",
|
||||||
|
customerId: "customer-456",
|
||||||
|
issueDate: "2026-05-08",
|
||||||
|
dueDate: "2026-06-08",
|
||||||
|
items: [], // Invalid: no items
|
||||||
|
netTotal: 0,
|
||||||
|
taxTotal: 0,
|
||||||
|
grossTotal: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = invoiceSchema.safeParse(invalidInvoice);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Customer API Logic", () => {
|
||||||
|
it("should validate customer data", async () => {
|
||||||
|
const { customerSchema } = await import("@/lib/schemas");
|
||||||
|
|
||||||
|
const validCustomer = {
|
||||||
|
companyId: "company-123",
|
||||||
|
name: "Max Mustermann",
|
||||||
|
address: "Musterstraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
country: "DE",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = customerSchema.safeParse(validCustomer);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require companyId", async () => {
|
||||||
|
const { customerSchema } = await import("@/lib/schemas");
|
||||||
|
|
||||||
|
const invalidCustomer = {
|
||||||
|
name: "Max Mustermann",
|
||||||
|
address: "Musterstraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = customerSchema.safeParse(invalidCustomer);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Tax Calculation Integration", () => {
|
||||||
|
it("should calculate invoice totals from items", async () => {
|
||||||
|
const { calcItemAmounts, calcInvoiceTotals } = await import("@/lib/tax");
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
quantity: 10,
|
||||||
|
unitPrice: 100,
|
||||||
|
taxRate: 19,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quantity: 5,
|
||||||
|
unitPrice: 200,
|
||||||
|
taxRate: 7,
|
||||||
|
},
|
||||||
|
].map((item, idx) => ({
|
||||||
|
position: idx + 1,
|
||||||
|
description: `Service ${idx + 1}`,
|
||||||
|
...item,
|
||||||
|
...calcItemAmounts(item.quantity, item.unitPrice, item.taxRate),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totals = calcInvoiceTotals(items);
|
||||||
|
|
||||||
|
expect(totals.netTotal).toBe(2000); // 1000 + 1000
|
||||||
|
expect(totals.taxTotal).toBe(260); // 190 + 70 (5*200*7% = 70)
|
||||||
|
expect(totals.grossTotal).toBe(2260); // 1190 + 1070
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle Kleinunternehmer calculation", async () => {
|
||||||
|
const { calcItemAmountsKleinunternehmer, calcInvoiceTotals } = await import("@/lib/tax");
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
quantity: 10,
|
||||||
|
unitPrice: 100,
|
||||||
|
},
|
||||||
|
].map((item, idx) => ({
|
||||||
|
position: idx + 1,
|
||||||
|
description: "Service",
|
||||||
|
...item,
|
||||||
|
...calcItemAmountsKleinunternehmer(item.quantity, item.unitPrice),
|
||||||
|
taxRate: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totals = calcInvoiceTotals(items);
|
||||||
|
|
||||||
|
expect(totals.netTotal).toBe(1000);
|
||||||
|
expect(totals.taxTotal).toBe(0);
|
||||||
|
expect(totals.grossTotal).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Authentication Logic", () => {
|
||||||
|
it("should identify unauthorized requests", () => {
|
||||||
|
const user = null;
|
||||||
|
const isAuthenticated = user !== null;
|
||||||
|
expect(isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should identify authorized requests", () => {
|
||||||
|
const user = { id: "user-123", role: "ADMIN" };
|
||||||
|
const isAuthenticated = user !== null;
|
||||||
|
expect(isAuthenticated).toBe(true);
|
||||||
|
expect(user.role).toBe("ADMIN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import { testPrisma, setupTestDatabase, cleanupTestDatabase, createTestUser, createTestCompany, createTestCustomer } from "./setup";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration Tests for API Routes
|
||||||
|
*
|
||||||
|
* These tests require a test database.
|
||||||
|
* They will skip gracefully if the database is not available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("API Integration Tests (Database Required)", () => {
|
||||||
|
let testUser: any;
|
||||||
|
let testCompany: any;
|
||||||
|
let testCustomer: any;
|
||||||
|
let dbAvailable = false;
|
||||||
|
|
||||||
|
// Setup before all tests
|
||||||
|
beforeAll(async () => {
|
||||||
|
dbAvailable = await setupTestDatabase();
|
||||||
|
|
||||||
|
if (!dbAvailable) {
|
||||||
|
console.warn("Skipping database integration tests - no test database available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
testUser = await createTestUser("test@example.com", "testuser");
|
||||||
|
|
||||||
|
// Create test company
|
||||||
|
testCompany = await createTestCompany(testUser.id, "Integration Test GmbH");
|
||||||
|
|
||||||
|
// Create test customer
|
||||||
|
testCustomer = await createTestCustomer(testCompany.id, "Integration Test Customer");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup after all tests
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!dbAvailable) return;
|
||||||
|
await cleanupTestDatabase();
|
||||||
|
await testPrisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean data between tests (but keep user/company/customer)
|
||||||
|
beforeEach(async () => {
|
||||||
|
if (!dbAvailable) return;
|
||||||
|
|
||||||
|
// Delete invoices and related items
|
||||||
|
await testPrisma.invoiceItem.deleteMany({
|
||||||
|
where: { invoice: { companyId: testCompany.id } },
|
||||||
|
});
|
||||||
|
await testPrisma.invoice.deleteMany({
|
||||||
|
where: { companyId: testCompany.id },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to skip tests if no database
|
||||||
|
const dbTest = (name: string, fn: () => Promise<void>) => {
|
||||||
|
it(name, async () => {
|
||||||
|
if (!dbAvailable) {
|
||||||
|
console.warn(`Skipping "${name}" - no test database`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fn();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Companies API", () => {
|
||||||
|
dbTest("should list companies for a user", async () => {
|
||||||
|
const companies = await testPrisma.company.findMany({
|
||||||
|
where: { userId: testUser.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(companies).toBeDefined();
|
||||||
|
expect(companies.length).toBeGreaterThan(0);
|
||||||
|
expect(companies[0].name).toBe("Integration Test GmbH");
|
||||||
|
});
|
||||||
|
|
||||||
|
dbTest("should create a new company", async () => {
|
||||||
|
const newCompanyData = {
|
||||||
|
name: "New Test Company",
|
||||||
|
address: "New Street 1",
|
||||||
|
zip: "99999",
|
||||||
|
city: "Munich",
|
||||||
|
country: "DE",
|
||||||
|
};
|
||||||
|
|
||||||
|
const company = await testPrisma.company.create({
|
||||||
|
data: {
|
||||||
|
...newCompanyData,
|
||||||
|
userId: testUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(company).toBeDefined();
|
||||||
|
expect(company.name).toBe("New Test Company");
|
||||||
|
expect(company.userId).toBe(testUser.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Invoices API", () => {
|
||||||
|
dbTest("should create a new invoice", async () => {
|
||||||
|
const { calcItemAmounts, calcInvoiceTotals } = await import("@/lib/tax");
|
||||||
|
|
||||||
|
const invoiceData = {
|
||||||
|
companyId: testCompany.id,
|
||||||
|
customerId: testCustomer.id,
|
||||||
|
issueDate: new Date(),
|
||||||
|
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
kleinunternehmer: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
description: "Test Service",
|
||||||
|
quantity: 10,
|
||||||
|
unit: "Stunden",
|
||||||
|
unitPrice: 100,
|
||||||
|
taxRate: 19,
|
||||||
|
netAmount: 1000,
|
||||||
|
taxAmount: 190,
|
||||||
|
grossAmount: 1190,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recalculate server-side (as the API does)
|
||||||
|
const recalculatedItems = invoiceData.items.map(item => ({
|
||||||
|
...item,
|
||||||
|
...calcItemAmounts(item.quantity, item.unitPrice, item.taxRate),
|
||||||
|
}));
|
||||||
|
const totals = calcInvoiceTotals(recalculatedItems);
|
||||||
|
|
||||||
|
const invoice = await testPrisma.invoice.create({
|
||||||
|
data: {
|
||||||
|
companyId: invoiceData.companyId,
|
||||||
|
customerId: invoiceData.customerId,
|
||||||
|
issueDate: invoiceData.issueDate,
|
||||||
|
dueDate: invoiceData.dueDate,
|
||||||
|
status: "DRAFT",
|
||||||
|
kleinunternehmer: invoiceData.kleinunternehmer,
|
||||||
|
netTotal: totals.netTotal,
|
||||||
|
taxTotal: totals.taxTotal,
|
||||||
|
grossTotal: totals.grossTotal,
|
||||||
|
items: {
|
||||||
|
create: recalculatedItems.map((item, idx) => ({
|
||||||
|
position: idx + 1,
|
||||||
|
description: item.description,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
taxRate: item.taxRate,
|
||||||
|
netAmount: item.netAmount,
|
||||||
|
taxAmount: item.taxAmount,
|
||||||
|
grossAmount: item.grossAmount,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { items: true, customer: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invoice).toBeDefined();
|
||||||
|
expect(invoice.status).toBe("DRAFT");
|
||||||
|
expect(invoice.items).toHaveLength(1);
|
||||||
|
expect(invoice.grossTotal).toBe(1190);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Buchungen API", () => {
|
||||||
|
dbTest("should create a new Buchung", async () => {
|
||||||
|
const buchungData = {
|
||||||
|
companyId: testCompany.id,
|
||||||
|
date: new Date(),
|
||||||
|
account: "KASSE" as const,
|
||||||
|
type: "EINLAGE" as const,
|
||||||
|
amount: 1000,
|
||||||
|
description: "Initial investment",
|
||||||
|
isBusinessRecord: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buchung = await testPrisma.buchung.create({
|
||||||
|
data: buchungData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buchung).toBeDefined();
|
||||||
|
expect(buchung.account).toBe("KASSE");
|
||||||
|
expect(buchung.type).toBe("EINLAGE");
|
||||||
|
expect(buchung.amount.toNumber()).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration Test Setup
|
||||||
|
* Uses a separate test database to avoid polluting development data
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL ||
|
||||||
|
"mysql://annas_user:annas_password@localhost:3306/annas_rechnungen_test";
|
||||||
|
|
||||||
|
export const testPrisma = new PrismaClient({
|
||||||
|
datasources: {
|
||||||
|
db: {
|
||||||
|
url: TEST_DATABASE_URL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup test database: push schema and seed if needed
|
||||||
|
* Returns true if successful, false if database unavailable
|
||||||
|
*/
|
||||||
|
export async function setupTestDatabase(): Promise<boolean> {
|
||||||
|
console.log("Setting up test database...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Push schema to test database
|
||||||
|
execSync(`npx prisma db push --force-reset`, {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DATABASE_URL: TEST_DATABASE_URL,
|
||||||
|
},
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Test database ready");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Test database not available:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up test database
|
||||||
|
*/
|
||||||
|
export async function cleanupTestDatabase() {
|
||||||
|
// Delete all data in correct order (respecting foreign keys)
|
||||||
|
const tables = [
|
||||||
|
"audit_logs",
|
||||||
|
"invoice_items",
|
||||||
|
"invoices",
|
||||||
|
"buchung_kategorien",
|
||||||
|
"buchungen",
|
||||||
|
"anlagegueter",
|
||||||
|
"services",
|
||||||
|
"customers",
|
||||||
|
"companies",
|
||||||
|
"users",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
await testPrisma.$executeRawUnsafe(`DELETE FROM ${table}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test user
|
||||||
|
*/
|
||||||
|
export async function createTestUser(email: string, username: string) {
|
||||||
|
const bcrypt = await import("bcryptjs");
|
||||||
|
const passwordHash = await bcrypt.default.hash("test1234", 10);
|
||||||
|
|
||||||
|
return testPrisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
name: "Test User",
|
||||||
|
passwordHash,
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test company for a user
|
||||||
|
*/
|
||||||
|
export async function createTestCompany(userId: string, name: string = "Test GmbH") {
|
||||||
|
return testPrisma.company.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
address: "Teststraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
country: "DE",
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test customer for a company
|
||||||
|
*/
|
||||||
|
export async function createTestCustomer(companyId: string, name: string = "Test Customer") {
|
||||||
|
return testPrisma.customer.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
address: "Kundenstraße 1",
|
||||||
|
zip: "54321",
|
||||||
|
city: "Hamburg",
|
||||||
|
country: "DE",
|
||||||
|
companyId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate auth cookie for test requests
|
||||||
|
* (Simplified - in real scenario, you'd create a session)
|
||||||
|
*/
|
||||||
|
export function getAuthHeaders(userId: string) {
|
||||||
|
// For integration tests, we might mock the session or use a test session
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Test-User-Id": userId, // Custom header for test auth bypass
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
jahresAfa,
|
||||||
|
erwerbsjahrAfa,
|
||||||
|
afaFuerJahr,
|
||||||
|
kumulierteAfa,
|
||||||
|
buchwert,
|
||||||
|
assetStatus,
|
||||||
|
AnlagegutRaw,
|
||||||
|
} from "@/lib/afa";
|
||||||
|
|
||||||
|
describe("afa.ts - Asset Depreciation (§7 EStG)", () => {
|
||||||
|
const sampleAsset: AnlagegutRaw = {
|
||||||
|
anschaffungskosten: 12000,
|
||||||
|
nutzungsdauerJahre: 3,
|
||||||
|
restwert: 0,
|
||||||
|
anschaffungsdatum: "2024-03-15", // March 15, 2024
|
||||||
|
aktiv: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("jahresAfa", () => {
|
||||||
|
it("should calculate full annual depreciation", () => {
|
||||||
|
// 12000 over 3 years = 4000 per year
|
||||||
|
expect(jahresAfa(12000, 0, 3)).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle residual value (restwert)", () => {
|
||||||
|
// 12000 - 2000 restwert = 10000 over 3 years
|
||||||
|
expect(jahresAfa(12000, 2000, 3)).toBe(3333.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle different depreciation periods", () => {
|
||||||
|
// 24000 over 8 years = 3000 per year
|
||||||
|
expect(jahresAfa(24000, 0, 8)).toBe(3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("erwerbsjahrAfa", () => {
|
||||||
|
it("should calculate pro-rata depreciation in acquisition year", () => {
|
||||||
|
// Acquired March 15 = 10 months remaining (Mar-Dec, inclusive) = 10/12
|
||||||
|
// 4000 * (10/12) = 3333.33
|
||||||
|
const acqDate = new Date("2024-03-15");
|
||||||
|
const result = erwerbsjahrAfa(12000, 0, 3, acqDate);
|
||||||
|
expect(result).toBe(3333.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate full year if acquired in January", () => {
|
||||||
|
const acqDate = new Date("2024-01-01");
|
||||||
|
const result = erwerbsjahrAfa(12000, 0, 3, acqDate);
|
||||||
|
expect(result).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate 1/12 if acquired in December", () => {
|
||||||
|
const acqDate = new Date("2024-12-01");
|
||||||
|
const result = erwerbsjahrAfa(12000, 0, 3, acqDate);
|
||||||
|
expect(result).toBe(333.33);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("afaFuerJahr", () => {
|
||||||
|
it("should return 0 for years before acquisition", () => {
|
||||||
|
expect(afaFuerJahr(sampleAsset, 2023)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return pro-rata for acquisition year", () => {
|
||||||
|
// 2024 acquisition, 10 months = 3333.33
|
||||||
|
const result = afaFuerJahr(sampleAsset, 2024);
|
||||||
|
expect(result).toBe(3333.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return full annual amount for middle years", () => {
|
||||||
|
expect(afaFuerJahr(sampleAsset, 2025)).toBe(4000);
|
||||||
|
expect(afaFuerJahr(sampleAsset, 2026)).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 0 after full depreciation period", () => {
|
||||||
|
// 3 years: 2024, 2025, 2026 -> after 2026 = 0
|
||||||
|
expect(afaFuerJahr(sampleAsset, 2027)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle asset with residual value", () => {
|
||||||
|
const assetWithRestwert: AnlagegutRaw = {
|
||||||
|
anschaffungskosten: 5000,
|
||||||
|
nutzungsdauerJahre: 5,
|
||||||
|
restwert: 500,
|
||||||
|
anschaffungsdatum: "2024-01-01",
|
||||||
|
aktiv: true,
|
||||||
|
};
|
||||||
|
// (5000-500) / 5 = 900 per year
|
||||||
|
expect(afaFuerJahr(assetWithRestwert, 2024)).toBe(900);
|
||||||
|
expect(afaFuerJahr(assetWithRestwert, 2028)).toBe(900);
|
||||||
|
expect(afaFuerJahr(assetWithRestwert, 2029)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("kumulierteAfa", () => {
|
||||||
|
it("should calculate cumulative depreciation correctly", () => {
|
||||||
|
// 2024: 3333.33, 2025: 4000, 2026: 4000 = 11333.33
|
||||||
|
expect(kumulierteAfa(sampleAsset, 2026)).toBe(11333.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 0 for year before acquisition", () => {
|
||||||
|
expect(kumulierteAfa(sampleAsset, 2023)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return full depreciation after end of period", () => {
|
||||||
|
// After 3 years: 3333.33 + 4000 + 4000 = 11333.33
|
||||||
|
// But asset cost is 12000, so 12000 - 11333.33 = 666.67 remaining
|
||||||
|
const result = kumulierteAfa(sampleAsset, 2027);
|
||||||
|
expect(result).toBe(11333.33);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buchwert", () => {
|
||||||
|
it("should calculate book value correctly", () => {
|
||||||
|
// 2024: 3333.33 depreciation -> 12000 - 3333.33 = 8666.67
|
||||||
|
expect(buchwert(sampleAsset, 2024)).toBe(8666.67);
|
||||||
|
// 2025: another 4000 -> 8666.67 - 4000 = 4666.67
|
||||||
|
expect(buchwert(sampleAsset, 2025)).toBe(4666.67);
|
||||||
|
// 2026: another 4000 -> 4666.67 - 4000 = 666.67
|
||||||
|
expect(buchwert(sampleAsset, 2026)).toBe(666.67);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not go below residual value", () => {
|
||||||
|
const assetWithRestwert: AnlagegutRaw = {
|
||||||
|
anschaffungskosten: 5000,
|
||||||
|
nutzungsdauerJahre: 5,
|
||||||
|
restwert: 500,
|
||||||
|
anschaffungsdatum: "2024-01-01",
|
||||||
|
aktiv: true,
|
||||||
|
};
|
||||||
|
// After full depreciation: 5000 - (900 * 5) = 500
|
||||||
|
expect(buchwert(assetWithRestwert, 2028)).toBe(500);
|
||||||
|
expect(buchwert(assetWithRestwert, 2030)).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return acquisition cost for year before acquisition", () => {
|
||||||
|
expect(buchwert(sampleAsset, 2023)).toBe(12000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assetStatus", () => {
|
||||||
|
it("should return 'inaktiv' for inactive assets", () => {
|
||||||
|
const inactive: AnlagegutRaw = { ...sampleAsset, aktiv: false };
|
||||||
|
expect(assetStatus(inactive, 2025)).toBe("inaktiv");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 'aktiv' for active assets within depreciation period", () => {
|
||||||
|
expect(assetStatus(sampleAsset, 2024)).toBe("aktiv");
|
||||||
|
expect(assetStatus(sampleAsset, 2025)).toBe("aktiv");
|
||||||
|
expect(assetStatus(sampleAsset, 2026)).toBe("aktiv");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 'vollständig abgeschrieben' after depreciation period", () => {
|
||||||
|
expect(assetStatus(sampleAsset, 2027)).toBe("vollständig abgeschrieben");
|
||||||
|
expect(assetStatus(sampleAsset, 2030)).toBe("vollständig abgeschrieben");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Decimal } from "@prisma/client";
|
||||||
|
|
||||||
|
describe("Buchungen - Double-Entry Bookkeeping Logic", () => {
|
||||||
|
describe("TransactionAccount Enum", () => {
|
||||||
|
it("should have KASSE and BANK accounts", () => {
|
||||||
|
// These are the two main accounts for German bookkeeping
|
||||||
|
const accounts = ["KASSE", "BANK"];
|
||||||
|
expect(accounts).toContain("KASSE");
|
||||||
|
expect(accounts).toContain("BANK");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TransactionType Enum", () => {
|
||||||
|
it("should have EINLAGE and ENTNAHME types", () => {
|
||||||
|
// EINLAGE = Owner investment, ENTNAHME = Owner withdrawal
|
||||||
|
const types = ["EINLAGE", "ENTNAHME"];
|
||||||
|
expect(types).toContain("EINLAGE");
|
||||||
|
expect(types).toContain("ENTNAHME");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Zahlungsart Enum", () => {
|
||||||
|
it("should have KASSE and BANK payment methods", () => {
|
||||||
|
const paymentMethods = ["KASSE", "BANK"];
|
||||||
|
expect(paymentMethods).toContain("KASSE");
|
||||||
|
expect(paymentMethods).toContain("BANK");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Buchung Business Logic", () => {
|
||||||
|
it("should calculate correct sign for EINLAGE (positive)", () => {
|
||||||
|
// EINLAGE increases the company's assets
|
||||||
|
const amount = 1000;
|
||||||
|
const type = "EINLAGE";
|
||||||
|
|
||||||
|
// In German bookkeeping: EINLAGE is recorded as positive (credit to equity)
|
||||||
|
const signedAmount = type === "EINLAGE" ? amount : -amount;
|
||||||
|
expect(signedAmount).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate correct sign for ENTNAHME (negative)", () => {
|
||||||
|
// ENTNAHME decreases the company's assets
|
||||||
|
const amount = 500;
|
||||||
|
const type = "ENTNAHME";
|
||||||
|
|
||||||
|
// In German bookkeeping: ENTNAHME is recorded as negative (debit to equity)
|
||||||
|
const signedAmount = type === "EINLAGE" ? amount : -amount;
|
||||||
|
expect(signedAmount).toBe(-500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should identify business records correctly", () => {
|
||||||
|
// Business records (isBusinessRecord = true) come from Einnahmen/Ausgaben
|
||||||
|
const isBusinessRecord = true;
|
||||||
|
const hasKategorie = true;
|
||||||
|
|
||||||
|
expect(isBusinessRecord).toBe(true);
|
||||||
|
expect(hasKategorie).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle non-business records (private)", () => {
|
||||||
|
// Non-business records might not have a kategorie
|
||||||
|
const isBusinessRecord = false;
|
||||||
|
const kategorie = null;
|
||||||
|
|
||||||
|
expect(isBusinessRecord).toBe(false);
|
||||||
|
expect(kategorie).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate Decimal precision for amounts", () => {
|
||||||
|
// Prisma Decimal(10,2) - max 10 digits, 2 decimal places
|
||||||
|
const amount = 12345678.90; // 8 digits before decimal, 2 after
|
||||||
|
const maxAmount = 99999999.99; // Max for DECIMAL(10,2)
|
||||||
|
|
||||||
|
expect(amount).toBeLessThanOrEqual(maxAmount);
|
||||||
|
expect(Number(amount.toFixed(2))).toBe(12345678.9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Buchung Link Logic (Linked Transactions)", () => {
|
||||||
|
it("should allow linking related transactions", () => {
|
||||||
|
// Example: An invoice payment might be linked to the invoice
|
||||||
|
const buchungId = "buchung-123";
|
||||||
|
const linkedBuchungId = "buchung-456";
|
||||||
|
|
||||||
|
// A Buchung can be linked to another (e.g., invoice payment -> invoice)
|
||||||
|
const link = { source: buchungId, target: linkedBuchungId };
|
||||||
|
expect(link.source).toBe("buchung-123");
|
||||||
|
expect(link.target).toBe("buchung-456");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Kategorie Logic", () => {
|
||||||
|
it("should have unique category names per company", () => {
|
||||||
|
// BuchungKategorie has @@unique([companyId, name, typ])
|
||||||
|
const companyId = "company-123";
|
||||||
|
const categories = [
|
||||||
|
{ companyId, name: "Fußpflege", typ: "EINNAHME" },
|
||||||
|
{ companyId, name: "Miete", typ: "AUSGABE" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const uniqueCheck = new Set(categories.map(c => `${c.companyId}-${c.name}-${c.typ}`));
|
||||||
|
expect(uniqueCheck.size).toBe(categories.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should distinguish between EINNAHME and AUSGABE", () => {
|
||||||
|
const einnahme: "EINNAHME" = "EINNAHME";
|
||||||
|
const ausgabe: "AUSGABE" = "AUSGABE";
|
||||||
|
|
||||||
|
expect(einnahme).toBe("EINNAHME");
|
||||||
|
expect(ausgabe).toBe("AUSGABE");
|
||||||
|
expect(einnahme).not.toBe(ausgabe);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Date-Based Queries", () => {
|
||||||
|
it("should filter Buchungen by date range", () => {
|
||||||
|
const buchungen = [
|
||||||
|
{ date: new Date("2026-01-15"), amount: 100 },
|
||||||
|
{ date: new Date("2026-02-20"), amount: 200 },
|
||||||
|
{ date: new Date("2026-03-10"), amount: 300 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const startDate = new Date("2026-02-01");
|
||||||
|
const endDate = new Date("2026-03-31");
|
||||||
|
|
||||||
|
const filtered = buchungen.filter(b =>
|
||||||
|
b.date >= startDate && b.date <= endDate
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(filtered).toHaveLength(2);
|
||||||
|
expect(filtered[0].amount).toBe(200);
|
||||||
|
expect(filtered[1].amount).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should group Buchungen by month for reports", () => {
|
||||||
|
const buchungen = [
|
||||||
|
{ date: new Date("2026-01-10"), amount: 100 },
|
||||||
|
{ date: new Date("2026-01-20"), amount: 150 },
|
||||||
|
{ date: new Date("2026-02-05"), amount: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const grouped = buchungen.reduce((acc, b) => {
|
||||||
|
const month = b.date.getMonth(); // 0-indexed
|
||||||
|
acc[month] = (acc[month] || 0) + b.amount;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, number>);
|
||||||
|
|
||||||
|
expect(grouped[0]).toBe(250); // January (month 0)
|
||||||
|
expect(grouped[1]).toBe(200); // February (month 1)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
isDebugMode,
|
||||||
|
setDebugMode,
|
||||||
|
validateTaxId,
|
||||||
|
validateVatId,
|
||||||
|
validateIban,
|
||||||
|
validateBic,
|
||||||
|
validateWebsite,
|
||||||
|
validateCompanyForm,
|
||||||
|
getFieldError,
|
||||||
|
hasFieldError,
|
||||||
|
type ValidationError,
|
||||||
|
type CompanyFormData,
|
||||||
|
} from "@/lib/client-validation";
|
||||||
|
|
||||||
|
describe("client-validation.ts", () => {
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {};
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store[key] || null,
|
||||||
|
setItem: (key: string, value: string) => { store[key] = value; },
|
||||||
|
removeItem: (key: string) => { delete store[key]; },
|
||||||
|
clear: () => { store = {}; },
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset localStorage mock
|
||||||
|
localStorageMock.clear();
|
||||||
|
(global as any).localStorage = localStorageMock;
|
||||||
|
(global as any).window = { localStorage: localStorageMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(global as any).window = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Debug Mode", () => {
|
||||||
|
it("should be disabled by default (no window)", () => {
|
||||||
|
(global as any).window = undefined;
|
||||||
|
expect(isDebugMode()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect debug mode from localStorage", () => {
|
||||||
|
setDebugMode(true);
|
||||||
|
expect(isDebugMode()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disable debug mode", () => {
|
||||||
|
setDebugMode(true);
|
||||||
|
expect(isDebugMode()).toBe(true);
|
||||||
|
|
||||||
|
setDebugMode(false);
|
||||||
|
expect(isDebugMode()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateTaxId", () => {
|
||||||
|
it("should return null for empty/null values", () => {
|
||||||
|
expect(validateTaxId("")).toBeNull();
|
||||||
|
expect(validateTaxId(null)).toBeNull();
|
||||||
|
expect(validateTaxId(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate correct 10-digit tax ID", () => {
|
||||||
|
expect(validateTaxId("1234567890")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid tax IDs", () => {
|
||||||
|
const result = validateTaxId("123"); // Too short
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.field).toBe("taxId");
|
||||||
|
expect(result?.message).toContain("10 Ziffern");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject tax ID with letters", () => {
|
||||||
|
const result = validateTaxId("abc4567890");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateVatId", () => {
|
||||||
|
it("should return null for empty/null values", () => {
|
||||||
|
expect(validateVatId("")).toBeNull();
|
||||||
|
expect(validateVatId(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate correct DE VAT ID", () => {
|
||||||
|
expect(validateVatId("DE123456789")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject VAT ID without DE prefix", () => {
|
||||||
|
const result = validateVatId("1234567890");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.field).toBe("vatId");
|
||||||
|
expect(result?.message).toContain("DE + 9 Ziffern");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject VAT ID with wrong length", () => {
|
||||||
|
const result = validateVatId("DE12345"); // Too short
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateIban", () => {
|
||||||
|
it("should return null for empty/null values", () => {
|
||||||
|
expect(validateIban("")).toBeNull();
|
||||||
|
expect(validateIban(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate correct IBAN", () => {
|
||||||
|
expect(validateIban("DE89370400440532013000")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid IBAN", () => {
|
||||||
|
const result = validateIban("INVALID");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.field).toBe("bankIban");
|
||||||
|
expect(result?.message).toContain("IBAN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateBic", () => {
|
||||||
|
it("should return null for empty/null values", () => {
|
||||||
|
expect(validateBic("")).toBeNull();
|
||||||
|
expect(validateBic(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate correct BIC", () => {
|
||||||
|
expect(validateBic("DEUTDEFF")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid BIC", () => {
|
||||||
|
const result = validateBic("123"); // Too short
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.field).toBe("bankBic");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateWebsite", () => {
|
||||||
|
it("should return null for empty/null values", () => {
|
||||||
|
expect(validateWebsite("")).toBeNull();
|
||||||
|
expect(validateWebsite(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate correct website URLs", () => {
|
||||||
|
expect(validateWebsite("https://example.com")).toBeNull();
|
||||||
|
expect(validateWebsite("http://example.com")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject website without protocol", () => {
|
||||||
|
const result = validateWebsite("example.com");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.field).toBe("website");
|
||||||
|
expect(result?.message).toContain("http:// oder https://");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject too long website URLs", () => {
|
||||||
|
const longUrl = "https://" + "a".repeat(250);
|
||||||
|
const result = validateWebsite(longUrl);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.message).toContain("255");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateCompanyForm", () => {
|
||||||
|
const validFormData: CompanyFormData = {
|
||||||
|
name: "Test GmbH",
|
||||||
|
address: "Hauptstraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should pass validation for valid data", () => {
|
||||||
|
const errors = validateCompanyForm(validFormData);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require name", () => {
|
||||||
|
const data = { ...validFormData, name: "" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
expect(errors[0].field).toBe("name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require address", () => {
|
||||||
|
const data = { ...validFormData, address: "" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const addressError = errors.find(e => e.field === "address");
|
||||||
|
expect(addressError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require zip", () => {
|
||||||
|
const data = { ...validFormData, zip: "" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const zipError = errors.find(e => e.field === "zip");
|
||||||
|
expect(zipError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require city", () => {
|
||||||
|
const data = { ...validFormData, city: "" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const cityError = errors.find(e => e.field === "city");
|
||||||
|
expect(cityError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate zip format (digits only)", () => {
|
||||||
|
const data = { ...validFormData, zip: "abc" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const zipError = errors.find(e => e.field === "zip");
|
||||||
|
expect(zipError).toBeDefined();
|
||||||
|
expect(zipError?.message).toContain("Zahlen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate optional fields when provided", () => {
|
||||||
|
const data = {
|
||||||
|
...validFormData,
|
||||||
|
taxId: "123", // Invalid
|
||||||
|
vatId: "INVALID", // Invalid
|
||||||
|
website: "example.com", // Invalid (no protocol)
|
||||||
|
};
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
expect(errors.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept valid optional fields", () => {
|
||||||
|
const data = {
|
||||||
|
...validFormData,
|
||||||
|
taxId: "1234567890",
|
||||||
|
vatId: "DE123456789",
|
||||||
|
website: "https://example.com",
|
||||||
|
};
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const hasTaxIdError = errors.some(e => e.field === "taxId");
|
||||||
|
const hasVatIdError = errors.some(e => e.field === "vatId");
|
||||||
|
const hasWebsiteError = errors.some(e => e.field === "website");
|
||||||
|
expect(hasTaxIdError).toBe(false);
|
||||||
|
expect(hasVatIdError).toBe(false);
|
||||||
|
expect(hasWebsiteError).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate email format", () => {
|
||||||
|
const data = { ...validFormData, email: "invalid-email" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const emailError = errors.find(e => e.field === "email");
|
||||||
|
expect(emailError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept valid email", () => {
|
||||||
|
const data = { ...validFormData, email: "test@example.com" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const emailError = errors.find(e => e.field === "email");
|
||||||
|
expect(emailError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate field length limits", () => {
|
||||||
|
const data = {
|
||||||
|
...validFormData,
|
||||||
|
name: "a".repeat(300), // Too long
|
||||||
|
phone: "a".repeat(30), // Too long
|
||||||
|
};
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFieldError", () => {
|
||||||
|
it("should return error message for field", () => {
|
||||||
|
const errors: ValidationError[] = [
|
||||||
|
{ field: "name", message: "Name required" },
|
||||||
|
{ field: "email", message: "Invalid email" },
|
||||||
|
];
|
||||||
|
expect(getFieldError("name", errors)).toBe("Name required");
|
||||||
|
expect(getFieldError("email", errors)).toBe("Invalid email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for field without error", () => {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
expect(getFieldError("name", errors)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasFieldError", () => {
|
||||||
|
it("should return true if field has error", () => {
|
||||||
|
const errors: ValidationError[] = [
|
||||||
|
{ field: "name", message: "Name required" },
|
||||||
|
];
|
||||||
|
expect(hasFieldError("name", errors)).toBe(true);
|
||||||
|
expect(hasFieldError("email", errors)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for empty errors", () => {
|
||||||
|
expect(hasFieldError("name", [])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
EINNAHME_KATEGORIEN,
|
||||||
|
EINNAHME_LABELS,
|
||||||
|
type EinnahmeKategorieKey,
|
||||||
|
} from "@/lib/einnahmen";
|
||||||
|
import {
|
||||||
|
AUSGABE_KATEGORIEN,
|
||||||
|
KATEGORIE_LABELS,
|
||||||
|
type AusgabeKategorieKey,
|
||||||
|
} from "@/lib/ausgaben";
|
||||||
|
|
||||||
|
describe("einnahmen.ts - Revenue Categories", () => {
|
||||||
|
it("should have all expected categories", () => {
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("FUSSPFLEGE");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("PRIVATEINLAGEN");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("DARLEHEN");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("STEUERERSTATTUNGEN");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("ZINSERTRAEGE");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("VERMIETUNG_VERPACHTUNG");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("VERAEUSSERUNGSERLOES");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("EIGENVERBRAUCH");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("SONSTIGE_EINNAHMEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have 10 revenue categories", () => {
|
||||||
|
expect(EINNAHME_KATEGORIEN).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have labels for all categories", () => {
|
||||||
|
EINNAHME_KATEGORIEN.forEach((key) => {
|
||||||
|
expect(EINNAHME_LABELS[key as EinnahmeKategorieKey]).toBeDefined();
|
||||||
|
expect(typeof EINNAHME_LABELS[key as EinnahmeKategorieKey]).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have correct labels", () => {
|
||||||
|
expect(EINNAHME_LABELS.FUSSPFLEGE).toBe("Fußpflege/Verkauf/Gutscheine");
|
||||||
|
expect(EINNAHME_LABELS.PRIVATEINLAGEN).toBe("Privateinlagen");
|
||||||
|
expect(EINNAHME_LABELS.DARLEHEN).toBe("Darlehen");
|
||||||
|
expect(EINNAHME_LABELS.ZINSERTRAEGE).toBe("Zinserträge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have valid TypeScript types", () => {
|
||||||
|
const testKey: EinnahmeKategorieKey = "FUSSPFLEGE";
|
||||||
|
expect(testKey).toBe("FUSSPFLEGE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ausgaben.ts - Expense Categories", () => {
|
||||||
|
it("should have all expected categories", () => {
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("WAREN_ROHSTOFFE");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("GERINGWERTIGE_WIRTSCHAFTSGUETER");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("ABSCHREIBUNGEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("MIETE");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("STROM_WASSER");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("TELEKOMMUNIKATION");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("FORTBILDUNG_MESSEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("BEITRAEGE");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("VERSICHERUNGEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("WERBEKOSTEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("ZINSEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("REISEKOSTEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("REPARATUREN_INSTANDHALTUNG");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("BUEROBEDARF");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("REPRAESENTATIONSKOSTEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("SONSTIGER_BETRIEBSBEDARF");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("NEBENKOSTEN_GELDVERKEHR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have 17 expense categories", () => {
|
||||||
|
expect(AUSGABE_KATEGORIEN).toHaveLength(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have labels for all categories", () => {
|
||||||
|
AUSGABE_KATEGORIEN.forEach((key) => {
|
||||||
|
expect(KATEGORIE_LABELS[key as AusgabeKategorieKey]).toBeDefined();
|
||||||
|
expect(typeof KATEGORIE_LABELS[key as AusgabeKategorieKey]).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have correct labels", () => {
|
||||||
|
expect(KATEGORIE_LABELS.WAREN_ROHSTOFFE).toBe("Waren, Rohstoffe, Hilfsstoffe");
|
||||||
|
expect(KATEGORIE_LABELS.GERINGWERTIGE_WIRTSCHAFTSGUETER).toBe(
|
||||||
|
"Geringwertige Wirtschaftsgüter"
|
||||||
|
);
|
||||||
|
expect(KATEGORIE_LABELS.MIETE).toBe("Miete");
|
||||||
|
expect(KATEGORIE_LABELS.ZINSEN).toBe("Zinsen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have valid TypeScript types", () => {
|
||||||
|
const testKey: AusgabeKategorieKey = "MIETE";
|
||||||
|
expect(testKey).toBe("MIETE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Category Integration", () => {
|
||||||
|
it("should not have overlapping keys between revenue and expenses", () => {
|
||||||
|
const overlap = EINNAHME_KATEGORIEN.filter((key) =>
|
||||||
|
AUSGABE_KATEGORIEN.includes(key as any)
|
||||||
|
);
|
||||||
|
expect(overlap).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have consistent naming convention (uppercase with underscores)", () => {
|
||||||
|
const validPattern = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
|
||||||
|
|
||||||
|
EINNAHME_KATEGORIEN.forEach((key) => {
|
||||||
|
expect(key).toMatch(validPattern);
|
||||||
|
});
|
||||||
|
|
||||||
|
AUSGABE_KATEGORIEN.forEach((key) => {
|
||||||
|
expect(key).toMatch(validPattern);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import prisma from "@/lib/prisma.server";
|
||||||
|
import { generateInvoiceNumber } from "@/lib/invoice-number.server";
|
||||||
|
|
||||||
|
// Mock the Prisma client
|
||||||
|
vi.mock("@/lib/prisma.server", () => ({
|
||||||
|
default: {
|
||||||
|
company: {
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("invoice-number.server.ts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate invoice number with year and sequence", async () => {
|
||||||
|
const mockCompany = {
|
||||||
|
invoicePrefix: "RE",
|
||||||
|
invoiceSequence: 5, // Already incremented by Prisma
|
||||||
|
};
|
||||||
|
|
||||||
|
(prisma.company.update as any).mockResolvedValue(mockCompany);
|
||||||
|
|
||||||
|
const result = await generateInvoiceNumber("company-123");
|
||||||
|
|
||||||
|
// invoiceSequence is already 5 (after increment), so we expect 005
|
||||||
|
expect(result).toBe("RE-2026-005");
|
||||||
|
expect(prisma.company.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: "company-123" },
|
||||||
|
data: { invoiceSequence: { increment: 1 } },
|
||||||
|
select: { invoicePrefix: true, invoiceSequence: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pad sequence with zeros", async () => {
|
||||||
|
const mockCompany = {
|
||||||
|
invoicePrefix: "RG",
|
||||||
|
invoiceSequence: 2, // Already incremented by Prisma
|
||||||
|
};
|
||||||
|
|
||||||
|
(prisma.company.update as any).mockResolvedValue(mockCompany);
|
||||||
|
|
||||||
|
const result = await generateInvoiceNumber("company-456");
|
||||||
|
|
||||||
|
expect(result).toBe("RG-2026-002");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle custom prefix", async () => {
|
||||||
|
const mockCompany = {
|
||||||
|
invoicePrefix: "INV",
|
||||||
|
invoiceSequence: 10, // Already incremented by Prisma
|
||||||
|
};
|
||||||
|
|
||||||
|
(prisma.company.update as any).mockResolvedValue(mockCompany);
|
||||||
|
|
||||||
|
const result = await generateInvoiceNumber("company-789");
|
||||||
|
|
||||||
|
expect(result).toBe("INV-2026-010");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_AUSGABE_KATEGORIEN,
|
||||||
|
DEFAULT_EINNAHME_KATEGORIEN,
|
||||||
|
} from "@/lib/kategorie-defaults";
|
||||||
|
|
||||||
|
describe("kategorie-defaults.ts", () => {
|
||||||
|
describe("DEFAULT_AUSGABE_KATEGORIEN", () => {
|
||||||
|
it("should have 15 default expense categories", () => {
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toHaveLength(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include common expense categories", () => {
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Waren, Rohstoffe, Hilfsstoffe");
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Miete");
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Strom, Wasser");
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Telekommunikationskosten");
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Bürobedarf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include GERINGWERTIGE_WIRTSCHAFTSGÜTER", () => {
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Geringwertige Wirtschaftsgüter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Abschreibungen", () => {
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Abschreibungen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Zinsen", () => {
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Zinsen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Reisekosten", () => {
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Reisekosten");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have unique values", () => {
|
||||||
|
const unique = new Set(DEFAULT_AUSGABE_KATEGORIEN);
|
||||||
|
expect(unique.size).toBe(DEFAULT_AUSGABE_KATEGORIEN.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not have empty strings", () => {
|
||||||
|
DEFAULT_AUSGABE_KATEGORIEN.forEach((cat) => {
|
||||||
|
expect(cat).not.toBe("");
|
||||||
|
expect(cat.trim().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DEFAULT_EINNAHME_KATEGORIEN", () => {
|
||||||
|
it("should have 9 default revenue categories", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toHaveLength(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Fußpflege category", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Fußpflege/Verkauf/Gutscheine");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Privateinlagen", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Privateinlagen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Darlehen", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Darlehen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Steuererstattungen", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Steuererstattungen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Zinserträge", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Zinserträge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Miet-/Pachteinnahmen", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Miet-/Pachteinnahmen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Veräußerungserlöse", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Veräußerungserlöse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have unique values", () => {
|
||||||
|
const unique = new Set(DEFAULT_EINNAHME_KATEGORIEN);
|
||||||
|
expect(unique.size).toBe(DEFAULT_EINNAHME_KATEGORIEN.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not have empty strings", () => {
|
||||||
|
DEFAULT_EINNAHME_KATEGORIEN.forEach((cat) => {
|
||||||
|
expect(cat).not.toBe("");
|
||||||
|
expect(cat.trim().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Category Integration", () => {
|
||||||
|
it("should not have overlapping categories between income and expenses", () => {
|
||||||
|
const overlap = DEFAULT_AUSGABE_KATEGORIEN.filter((cat) =>
|
||||||
|
DEFAULT_EINNAHME_KATEGORIEN.includes(cat)
|
||||||
|
);
|
||||||
|
expect(overlap).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have consistent German language", () => {
|
||||||
|
// Check that categories contain German umlauts or common German words
|
||||||
|
const allCategories = [
|
||||||
|
...DEFAULT_AUSGABE_KATEGORIEN,
|
||||||
|
...DEFAULT_EINNAHME_KATEGORIEN,
|
||||||
|
];
|
||||||
|
|
||||||
|
allCategories.forEach((cat) => {
|
||||||
|
expect(cat.length).toBeGreaterThan(0);
|
||||||
|
// Should not contain numbers at start (heuristic check)
|
||||||
|
expect(/^\d/.test(cat)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should cover common business categories", () => {
|
||||||
|
// Expenses should cover: rent, utilities, telecom, office supplies
|
||||||
|
const expenseStr = DEFAULT_AUSGABE_KATEGORIEN.join(" ");
|
||||||
|
expect(expenseStr).toContain("Miete");
|
||||||
|
expect(expenseStr).toContain("Bürobedarf");
|
||||||
|
|
||||||
|
// Revenue should cover: services, loans, interest
|
||||||
|
const revenueStr = DEFAULT_EINNAHME_KATEGORIEN.join(" ");
|
||||||
|
expect(revenueStr).toContain("Darlehen");
|
||||||
|
expect(revenueStr).toContain("Zinserträge");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Practical Usage", () => {
|
||||||
|
it("should be usable as default values for new companies", () => {
|
||||||
|
// Simulate creating default categories for a new company
|
||||||
|
const companyId = "new-company-123";
|
||||||
|
|
||||||
|
const defaultExpenseCategories = DEFAULT_AUSGABE_KATEGORIEN.map((name) => ({
|
||||||
|
companyId,
|
||||||
|
name,
|
||||||
|
typ: "AUSGABE" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultRevenueCategories = DEFAULT_EINNAHME_KATEGORIEN.map((name) => ({
|
||||||
|
companyId,
|
||||||
|
name,
|
||||||
|
typ: "EINNAHME" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(defaultExpenseCategories).toHaveLength(15);
|
||||||
|
expect(defaultRevenueCategories).toHaveLength(9);
|
||||||
|
expect(defaultExpenseCategories[0].typ).toBe("AUSGABE");
|
||||||
|
expect(defaultRevenueCategories[0].typ).toBe("EINNAHME");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow adding custom expense categories", () => {
|
||||||
|
const customCategories: string[] = [
|
||||||
|
...DEFAULT_AUSGABE_KATEGORIEN,
|
||||||
|
"Custom Expense Category",
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(customCategories.length).toBe(16); // 15 defaults + 1 custom
|
||||||
|
expect(customCategories[15]).toBe("Custom Expense Category");
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import {
|
||||||
|
currencySchema,
|
||||||
|
taxRateSchema,
|
||||||
|
ibanSchema,
|
||||||
|
taxIdSchema,
|
||||||
|
vatIdSchema,
|
||||||
|
invoiceSchema,
|
||||||
|
companySchema,
|
||||||
|
customerSchema,
|
||||||
|
} from "@/lib/schemas";
|
||||||
|
|
||||||
|
describe("schemas.ts - Zod Validation", () => {
|
||||||
|
describe("currencySchema", () => {
|
||||||
|
it("should accept valid currency values", () => {
|
||||||
|
expect(currencySchema.parse(0)).toBe(0);
|
||||||
|
expect(currencySchema.parse(100)).toBe(100);
|
||||||
|
expect(currencySchema.parse(99.99)).toBe(99.99);
|
||||||
|
expect(currencySchema.parse(1000.5)).toBe(1000.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject negative values", () => {
|
||||||
|
expect(() => currencySchema.parse(-1)).toThrow("Geldbeträge dürfen nicht negativ sein");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject more than 2 decimal places", () => {
|
||||||
|
expect(() => currencySchema.parse(1.999)).toThrow(
|
||||||
|
"Geldbeträge dürfen maximal 2 Dezimalstellen haben"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept exactly 2 decimal places", () => {
|
||||||
|
expect(currencySchema.parse(1.99)).toBe(1.99);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("taxRateSchema", () => {
|
||||||
|
it("should accept valid German tax rates", () => {
|
||||||
|
expect(taxRateSchema.parse(0)).toBe(0);
|
||||||
|
expect(taxRateSchema.parse(7)).toBe(7);
|
||||||
|
expect(taxRateSchema.parse(19)).toBe(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid tax rates", () => {
|
||||||
|
expect(() => taxRateSchema.parse(5)).toThrow(
|
||||||
|
"Steuersatz muss 0 (steuerfrei), 7 (ermäßigt) oder 19 (Standard) sein"
|
||||||
|
);
|
||||||
|
expect(() => taxRateSchema.parse(20)).toThrow();
|
||||||
|
expect(() => taxRateSchema.parse(15)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject non-integer values", () => {
|
||||||
|
expect(() => taxRateSchema.parse(7.5)).toThrow("Steuersatz muss eine ganze Zahl sein");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ibanSchema", () => {
|
||||||
|
it("should accept valid IBANs", () => {
|
||||||
|
expect(ibanSchema.parse("DE89370400440532013000")).toBe("DE89370400440532013000");
|
||||||
|
expect(ibanSchema.parse("AT611904300234573201")).toBe("AT611904300234573201");
|
||||||
|
expect(ibanSchema.parse("CH9300762011623852957")).toBe("CH9300762011623852957");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept empty string", () => {
|
||||||
|
expect(ibanSchema.parse("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid IBAN format", () => {
|
||||||
|
// No country code - starts with numbers
|
||||||
|
expect(() => ibanSchema.parse("1234567890")).toThrow();
|
||||||
|
// Too short - only 4 chars (need at least 5: 2 letters + 2 digits + 1)
|
||||||
|
expect(() => ibanSchema.parse("DE1")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("taxIdSchema", () => {
|
||||||
|
it("should accept valid 10-digit tax IDs", () => {
|
||||||
|
expect(taxIdSchema.parse("1234567890")).toBe("1234567890");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept empty string", () => {
|
||||||
|
expect(taxIdSchema.parse("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid tax IDs", () => {
|
||||||
|
expect(() => taxIdSchema.parse("123")).toThrow("Steuernummer muss 10 Ziffern haben");
|
||||||
|
expect(() => taxIdSchema.parse("12345678901")).toThrow();
|
||||||
|
expect(() => taxIdSchema.parse("abcdefghij")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vatIdSchema", () => {
|
||||||
|
it("should accept valid DE VAT IDs", () => {
|
||||||
|
expect(vatIdSchema.parse("DE123456789")).toBe("DE123456789");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept empty string", () => {
|
||||||
|
expect(vatIdSchema.parse("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid VAT IDs", () => {
|
||||||
|
expect(() => vatIdSchema.parse("DE123")).toThrow(
|
||||||
|
"USt-IdNr. muss im Format DE + 9 Ziffern sein"
|
||||||
|
);
|
||||||
|
expect(() => vatIdSchema.parse("1234567890")).toThrow();
|
||||||
|
expect(() => vatIdSchema.parse("DE12345678")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invoiceSchema", () => {
|
||||||
|
const validInvoice = {
|
||||||
|
companyId: "cm123abc",
|
||||||
|
customerId: "cm456def",
|
||||||
|
issueDate: "2026-05-08",
|
||||||
|
dueDate: "2026-06-08",
|
||||||
|
kleinunternehmer: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
description: "Web Development",
|
||||||
|
quantity: 10,
|
||||||
|
unit: "Stunden",
|
||||||
|
unitPrice: 100,
|
||||||
|
taxRate: 19,
|
||||||
|
netAmount: 1000,
|
||||||
|
taxAmount: 190,
|
||||||
|
grossAmount: 1190,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
netTotal: 1000,
|
||||||
|
taxTotal: 190,
|
||||||
|
grossTotal: 1190,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should accept valid invoice", () => {
|
||||||
|
const result = invoiceSchema.parse(validInvoice);
|
||||||
|
expect(result.companyId).toBe("cm123abc");
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require at least one item", () => {
|
||||||
|
const invalid = { ...validInvoice, items: [] };
|
||||||
|
expect(() => invoiceSchema.parse(invalid)).toThrow(
|
||||||
|
"Mindestens ein Rechnungsposition erforderlich"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept optional deliveryDate", () => {
|
||||||
|
const withDelivery = { ...validInvoice, deliveryDate: "2026-05-07" };
|
||||||
|
expect(() => invoiceSchema.parse(withDelivery)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid dates", () => {
|
||||||
|
const invalid = { ...validInvoice, issueDate: "not-a-date" };
|
||||||
|
expect(() => invoiceSchema.parse(invalid)).toThrow("Ungültiges Datum");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept optional notes with max length", () => {
|
||||||
|
const withNotes = { ...validInvoice, notes: "Test notes" };
|
||||||
|
expect(() => invoiceSchema.parse(withNotes)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("companySchema", () => {
|
||||||
|
const validCompany = {
|
||||||
|
name: "Test GmbH",
|
||||||
|
taxId: null,
|
||||||
|
vatId: null,
|
||||||
|
address: "Hauptstraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
country: "DE",
|
||||||
|
invoicePrefix: "RE",
|
||||||
|
kleinunternehmer: false,
|
||||||
|
bankIban: null,
|
||||||
|
// bankBic is optional, can be omitted or empty string
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should accept valid company", () => {
|
||||||
|
const result = companySchema.parse(validCompany);
|
||||||
|
expect(result.name).toBe("Test GmbH");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require name and address", () => {
|
||||||
|
const invalid = { ...validCompany, name: "" };
|
||||||
|
expect(() => companySchema.parse(invalid)).toThrow("Firmenname erforderlich");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate email format", () => {
|
||||||
|
const withEmail = { ...validCompany, email: "test@example.com" };
|
||||||
|
expect(() => companySchema.parse(withEmail)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid email", () => {
|
||||||
|
const invalid = { ...validCompany, email: "not-an-email" };
|
||||||
|
expect(() => companySchema.parse(invalid)).toThrow("Ungültige E-Mail-Adresse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate website starts with http/https", () => {
|
||||||
|
const withWebsite = { ...validCompany, website: "https://example.com" };
|
||||||
|
expect(() => companySchema.parse(withWebsite)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject website without protocol", () => {
|
||||||
|
const invalid = { ...validCompany, website: "example.com" };
|
||||||
|
expect(() => companySchema.parse(invalid)).toThrow(
|
||||||
|
"Website muss mit http:// oder https:// beginnen"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept valid IBAN", () => {
|
||||||
|
const withIban = { ...validCompany, bankIban: "DE89370400440532013000" };
|
||||||
|
expect(() => companySchema.parse(withIban)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("customerSchema", () => {
|
||||||
|
const validCustomer = {
|
||||||
|
companyId: "cm123abc",
|
||||||
|
name: "Max Mustermann",
|
||||||
|
address: "Musterstraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
country: "DE",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should accept valid customer", () => {
|
||||||
|
const result = customerSchema.parse(validCustomer);
|
||||||
|
expect(result.name).toBe("Max Mustermann");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require companyId", () => {
|
||||||
|
const invalid = { ...validCustomer, companyId: "" };
|
||||||
|
expect(() => customerSchema.parse(invalid)).toThrow("Mandant erforderlich");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate zip code format", () => {
|
||||||
|
const invalid = { ...validCustomer, zip: "abc" };
|
||||||
|
expect(() => customerSchema.parse(invalid)).toThrow(
|
||||||
|
"PLZ darf nur Zahlen, Bindestriche und Leerzeichen enthalten"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
TAX_RATES,
|
||||||
|
calcItemAmounts,
|
||||||
|
calcItemAmountsKleinunternehmer,
|
||||||
|
calcInvoiceTotals,
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
|
} from "@/lib/tax";
|
||||||
|
|
||||||
|
describe("tax.ts - German Tax Calculations", () => {
|
||||||
|
describe("TAX_RATES", () => {
|
||||||
|
it("should have correct tax rate values", () => {
|
||||||
|
expect(TAX_RATES).toEqual([
|
||||||
|
{ label: "19% MwSt. (Regelsteuersatz)", value: 19 },
|
||||||
|
{ label: "7% MwSt. (ermäßigt)", value: 7 },
|
||||||
|
{ label: "0% (steuerfrei / §13b UStG)", value: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calcItemAmounts", () => {
|
||||||
|
it("should calculate correct amounts for 19% tax", () => {
|
||||||
|
const result = calcItemAmounts(2, 100, 19);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netAmount: 200,
|
||||||
|
taxAmount: 38,
|
||||||
|
grossAmount: 238,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate correct amounts for 7% tax", () => {
|
||||||
|
const result = calcItemAmounts(1, 50, 7);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netAmount: 50,
|
||||||
|
taxAmount: 3.5,
|
||||||
|
grossAmount: 53.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tax-free items (0%)", () => {
|
||||||
|
const result = calcItemAmounts(3, 33.33, 0);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netAmount: 99.99,
|
||||||
|
taxAmount: 0,
|
||||||
|
grossAmount: 99.99,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle decimal quantities", () => {
|
||||||
|
const result = calcItemAmounts(1.5, 100, 19);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netAmount: 150,
|
||||||
|
taxAmount: 28.5,
|
||||||
|
grossAmount: 178.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should round to 2 decimal places", () => {
|
||||||
|
const result = calcItemAmounts(1, 33.333, 19);
|
||||||
|
// 33.333 * 19% = 6.333..., rounded to 6.33
|
||||||
|
expect(result.netAmount).toBe(33.33);
|
||||||
|
expect(result.taxAmount).toBe(6.33);
|
||||||
|
expect(result.grossAmount).toBe(39.66);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calcItemAmountsKleinunternehmer", () => {
|
||||||
|
it("should set tax to 0 for Kleinunternehmer", () => {
|
||||||
|
const result = calcItemAmountsKleinunternehmer(2, 100);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netAmount: 200,
|
||||||
|
taxAmount: 0,
|
||||||
|
grossAmount: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should treat gross as net for Kleinunternehmer", () => {
|
||||||
|
const result = calcItemAmountsKleinunternehmer(1, 50);
|
||||||
|
expect(result.netAmount).toBe(50);
|
||||||
|
expect(result.grossAmount).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calcInvoiceTotals", () => {
|
||||||
|
it("should sum up multiple invoice items", () => {
|
||||||
|
const items = [
|
||||||
|
{ netAmount: 100, taxAmount: 19, grossAmount: 119 },
|
||||||
|
{ netAmount: 50, taxAmount: 3.5, grossAmount: 53.5 },
|
||||||
|
];
|
||||||
|
const result = calcInvoiceTotals(items);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netTotal: 150,
|
||||||
|
taxTotal: 22.5,
|
||||||
|
grossTotal: 172.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty items array", () => {
|
||||||
|
const result = calcInvoiceTotals([]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netTotal: 0,
|
||||||
|
taxTotal: 0,
|
||||||
|
grossTotal: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should round totals to 2 decimal places", () => {
|
||||||
|
const items = [
|
||||||
|
{ netAmount: 33.333, taxAmount: 6.333, grossAmount: 39.666 },
|
||||||
|
{ netAmount: 66.666, taxAmount: 12.666, grossAmount: 79.332 },
|
||||||
|
];
|
||||||
|
const result = calcInvoiceTotals(items);
|
||||||
|
// calcInvoiceTotals uses Math.round which rounds 99.999 to 100
|
||||||
|
expect(result.netTotal).toBe(100);
|
||||||
|
expect(result.taxTotal).toBe(19);
|
||||||
|
expect(result.grossTotal).toBe(119);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle Kleinunternehmer invoice (no tax)", () => {
|
||||||
|
const items = [
|
||||||
|
{ netAmount: 100, taxAmount: 0, grossAmount: 100 },
|
||||||
|
{ netAmount: 200, taxAmount: 0, grossAmount: 200 },
|
||||||
|
];
|
||||||
|
const result = calcInvoiceTotals(items);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netTotal: 300,
|
||||||
|
taxTotal: 0,
|
||||||
|
grossTotal: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatCurrency", () => {
|
||||||
|
it("should format number to EUR currency", () => {
|
||||||
|
// Note: Intl.NumberFormat uses non-breaking space (\u00A0) before €
|
||||||
|
expect(formatCurrency(1234.56)).toBe("1.234,56\u00A0€");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle string input", () => {
|
||||||
|
expect(formatCurrency("999.99")).toBe("999,99\u00A0€");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format zero correctly", () => {
|
||||||
|
expect(formatCurrency(0)).toBe("0,00\u00A0€");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDate", () => {
|
||||||
|
it("should format Date object to German format", () => {
|
||||||
|
const date = new Date("2026-05-08");
|
||||||
|
expect(formatDate(date)).toBe("8.5.2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format date string to German format", () => {
|
||||||
|
expect(formatDate("2026-12-31")).toBe("31.12.2026");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tsconfigPaths()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
|
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json", "html"],
|
||||||
|
include: ["app/lib/**/*.ts", "app/lib/**/*.tsx"],
|
||||||
|
exclude: ["app/lib/**/*.server.ts"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
// Global test setup
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear all mocks before each test
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user