Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29619658fc | |||
| db953b1e28 | |||
| 586d5b2cc8 |
@@ -20,6 +20,18 @@ jobs:
|
||||
- name: Checkout
|
||||
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
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
@@ -53,3 +53,4 @@ data/documents/
|
||||
|
||||
.vscode/
|
||||
.react-router
|
||||
.continue
|
||||
|
||||
+9489
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16465
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
AGENTS.md
|
||||
copilot-instructions.md
|
||||
README.md
|
||||
CLAUDE.md
|
||||
IMPROVEMENTS_SUMMARY.md
|
||||
INTEGRATION_EXAMPLE.md
|
||||
tests/README.md
|
||||
app/lib/ERROR_LOGGING_GUIDE.md
|
||||
graphify-out/GRAPH_REPORT.md
|
||||
data/documents/cmmoo4v5p0000ykou0bcivjsn/migr-ein-cmn4ogjc20007dfmmex6engdh-1777488241127.pdf
|
||||
data/documents/cmmoo4v5p0000ykou0bcivjsn/migr-ein-cmn4ogjc20007dfmmex6engdh-1777488343334.pdf
|
||||
data/documents/cmootxs7r0000nvgjmhgbanvx/demo-invoice-1-1777790365605.pdf
|
||||
data/documents/cmootxs7r0000nvgjmhgbanvx/cmopen7qx000s2mzpnq44y0rb-1777790640988.pdf
|
||||
data/documents/cmootxs7r0000nvgjmhgbanvx/cmopejaq8000f2mzpnho6t72z-1777790461825.pdf
|
||||
public/file.svg
|
||||
public/window.svg
|
||||
public/globe.svg
|
||||
public/next.svg
|
||||
public/vercel.svg
|
||||
coverage/favicon.png
|
||||
coverage/sort-arrow-sprite.png
|
||||
@@ -0,0 +1,22 @@
|
||||
// graphify OpenCode plugin
|
||||
// Injects a knowledge graph reminder before bash tool calls when the graph exists.
|
||||
import { existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
export const GraphifyPlugin = async ({ directory }) => {
|
||||
let reminded = false;
|
||||
|
||||
return {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
if (reminded) return;
|
||||
if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
|
||||
|
||||
if (input.tool === "bash") {
|
||||
output.args.command =
|
||||
'echo "[graphify] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for god nodes and architecture context before searching files." && ' +
|
||||
output.args.command;
|
||||
reminded = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
# Annas Rechnungsmanager Agent Guidance
|
||||
|
||||
## Development Commands
|
||||
- Install: `npm install`
|
||||
- Dev server: `npm run dev` (Vite on http://localhost:5173)
|
||||
- Dev with DB: `npm run devfull` (starts docker-compose DB first)
|
||||
- Typecheck: `npm run typecheck` (react-router typegen + tsc)
|
||||
- Lint: `npm run lint` (eslint)
|
||||
- Test: `npm run test` (vitest run)
|
||||
- DB migrate: `npm run db:migrate` (prisma migrate dev)
|
||||
- DB seed: `npm run db:seed` (loads demo data)
|
||||
- DB studio: `npm run db:studio` (Prisma GUI)
|
||||
- Admin setup: `npm run setup-admin` (sets ADMIN_PASSWORD env var or prompts)
|
||||
- Reset password: `npm run reset-password -- --username <user> --password <pass>`
|
||||
- Build: `npm run build`
|
||||
- Start prod: `npm run start`
|
||||
|
||||
## Environment
|
||||
- `.env` required: DATABASE_URL and AUTH_SECRET
|
||||
- Dev: http://localhost:5173
|
||||
- Docker: http://localhost:3000 (first start requires ADMIN_PASSWORD)
|
||||
|
||||
## Docker Deployment
|
||||
- First start: `docker build -t annasrechnungsmanager:latest .` then `docker compose up -d` with ADMIN_PASSWORD set
|
||||
- Subsequent: `docker build -t annasrechnungsmanager:latest .` then `docker compose up -d --no-deps app`
|
||||
- Admin user created/updated on first start with ADMIN_PASSWORD
|
||||
|
||||
## Code Structure
|
||||
- `app/routes/` - file-based routing (React Router v7)
|
||||
- `app/routes/api.*` - REST API endpoints (loader/action)
|
||||
- `app/lib/` - data access, Prisma, tax, utils
|
||||
- `prisma/schema.prisma` - database schema
|
||||
- `scripts/` - setup-admin, reset-password
|
||||
|
||||
## Conventions
|
||||
- TypeScript strict; use unknown/guard checks for external data
|
||||
- UI: Tailwind v4 + shadcn/ui
|
||||
- Error handling: Remix redirect, json, badRequest
|
||||
- API contracts in `app/routes/api.*` must remain backward compatible
|
||||
- Document business logic for complex changes (taxes, invoice codes, UStG §14)
|
||||
|
||||
## Testing
|
||||
- Vitest: `npm run test` (run), `npm run test:watch` (watch)
|
||||
- No additional services required for unit tests
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a graphify knowledge graph at graphify-out/.
|
||||
|
||||
Rules:
|
||||
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
|
||||
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
|
||||
- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": [
|
||||
".opencode/plugins/graphify.js",
|
||||
"superpowers@git+https://github.com/obra/superpowers.git"
|
||||
]
|
||||
}
|
||||
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",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "npx ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
@@ -54,15 +58,21 @@
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.13.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vitest/ui": "^4.1.5",
|
||||
"eslint": "^9",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"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,152 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
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" | "ENTNAHME" = "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: "EINLAGE" | "ENTNAHME" = "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,245 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
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,160 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
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