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:
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user