- Added GitHub spec-kit for development workflow - Standardized file signatures to Descomplicar® format - Updated development configuration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
574 lines
18 KiB
PHP
574 lines
18 KiB
PHP
/**
|
|
* Descomplicar® Crescimento Digital
|
|
* https://descomplicar.pt
|
|
*/
|
|
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace DeskMoloni\Tests\Unit;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use Mockery;
|
|
|
|
/**
|
|
* Unit Test: Validation Service Business Logic
|
|
*
|
|
* This test MUST FAIL initially as part of TDD methodology.
|
|
* Tests validation rules and business logic without external dependencies.
|
|
*
|
|
* @group unit
|
|
* @group validation
|
|
*/
|
|
class ValidationServiceTest extends TestCase
|
|
{
|
|
private \DeskMoloni\ValidationService $validationService;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
// This will fail initially until ValidationService is implemented
|
|
$this->validationService = new \DeskMoloni\ValidationService();
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
Mockery::close();
|
|
}
|
|
|
|
/**
|
|
* Test client data validation rules
|
|
* This test will initially fail until validation implementation exists
|
|
*/
|
|
public function testClientDataValidation(): void
|
|
{
|
|
// Valid client data
|
|
$validClient = [
|
|
'company' => 'Valid Company Name',
|
|
'vat' => '123456789',
|
|
'email' => 'valid@example.com',
|
|
'phone' => '+351910000000',
|
|
'address' => 'Valid Address 123',
|
|
'city' => 'Lisboa',
|
|
'zip' => '1000-001',
|
|
'country' => 'Portugal'
|
|
];
|
|
|
|
$result = $this->validationService->validateClientData($validClient);
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertTrue($result['valid']);
|
|
$this->assertEmpty($result['errors']);
|
|
|
|
// Invalid client data - missing required fields
|
|
$invalidClient = [
|
|
'company' => '',
|
|
'vat' => '',
|
|
'email' => 'invalid-email',
|
|
'phone' => 'invalid-phone'
|
|
];
|
|
|
|
$result = $this->validationService->validateClientData($invalidClient);
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertFalse($result['valid']);
|
|
$this->assertNotEmpty($result['errors']);
|
|
$this->assertArrayHasKey('company', $result['errors']);
|
|
$this->assertArrayHasKey('vat', $result['errors']);
|
|
$this->assertArrayHasKey('email', $result['errors']);
|
|
$this->assertArrayHasKey('phone', $result['errors']);
|
|
}
|
|
|
|
/**
|
|
* Test VAT number validation for different countries
|
|
*/
|
|
public function testVatNumberValidation(): void
|
|
{
|
|
$validVatNumbers = [
|
|
'PT123456789', // Portugal
|
|
'ES12345678Z', // Spain
|
|
'FR12345678901', // France
|
|
'DE123456789', // Germany
|
|
'IT12345678901', // Italy
|
|
'123456789' // Default format
|
|
];
|
|
|
|
foreach ($validVatNumbers as $vatNumber) {
|
|
$this->assertTrue(
|
|
$this->validationService->isValidVAT($vatNumber),
|
|
"VAT number should be valid: {$vatNumber}"
|
|
);
|
|
}
|
|
|
|
$invalidVatNumbers = [
|
|
'', // Empty
|
|
'123', // Too short
|
|
'INVALID', // Non-numeric
|
|
'PT123', // Wrong format for Portugal
|
|
str_repeat('1', 20) // Too long
|
|
];
|
|
|
|
foreach ($invalidVatNumbers as $vatNumber) {
|
|
$this->assertFalse(
|
|
$this->validationService->isValidVAT($vatNumber),
|
|
"VAT number should be invalid: {$vatNumber}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test email validation with business rules
|
|
*/
|
|
public function testEmailValidation(): void
|
|
{
|
|
$validEmails = [
|
|
'user@example.com',
|
|
'user.name@example.com',
|
|
'user+tag@example.com',
|
|
'user123@example-domain.com',
|
|
'test@subdomain.example.com'
|
|
];
|
|
|
|
foreach ($validEmails as $email) {
|
|
$this->assertTrue(
|
|
$this->validationService->isValidEmail($email),
|
|
"Email should be valid: {$email}"
|
|
);
|
|
}
|
|
|
|
$invalidEmails = [
|
|
'', // Empty
|
|
'invalid', // No @ symbol
|
|
'invalid@', // No domain
|
|
'@example.com', // No user
|
|
'user@', // No domain
|
|
'user space@example.com', // Space in email
|
|
'user@example', // No TLD
|
|
'user@.com' // Invalid domain
|
|
];
|
|
|
|
foreach ($invalidEmails as $email) {
|
|
$this->assertFalse(
|
|
$this->validationService->isValidEmail($email),
|
|
"Email should be invalid: {$email}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test phone number validation for international formats
|
|
*/
|
|
public function testPhoneNumberValidation(): void
|
|
{
|
|
$validPhoneNumbers = [
|
|
'+351910000000', // Portugal mobile
|
|
'+351210000000', // Portugal landline
|
|
'+34600000000', // Spain mobile
|
|
'+33600000000', // France mobile
|
|
'+49170000000', // Germany mobile
|
|
'910000000', // Local format
|
|
'00351910000000' // International format
|
|
];
|
|
|
|
foreach ($validPhoneNumbers as $phone) {
|
|
$this->assertTrue(
|
|
$this->validationService->isValidPhone($phone),
|
|
"Phone number should be valid: {$phone}"
|
|
);
|
|
}
|
|
|
|
$invalidPhoneNumbers = [
|
|
'', // Empty
|
|
'123', // Too short
|
|
'abcdefghij', // Non-numeric
|
|
'+351', // Incomplete
|
|
'+351 910 000 000', // Spaces not allowed in some contexts
|
|
str_repeat('1', 20) // Too long
|
|
];
|
|
|
|
foreach ($invalidPhoneNumbers as $phone) {
|
|
$this->assertFalse(
|
|
$this->validationService->isValidPhone($phone),
|
|
"Phone number should be invalid: {$phone}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test product data validation
|
|
*/
|
|
public function testProductDataValidation(): void
|
|
{
|
|
$validProduct = [
|
|
'name' => 'Valid Product Name',
|
|
'reference' => 'PROD-001',
|
|
'price' => 99.99,
|
|
'category_id' => 1,
|
|
'has_stock' => true,
|
|
'stock' => 10
|
|
];
|
|
|
|
$result = $this->validationService->validateProductData($validProduct);
|
|
|
|
$this->assertTrue($result['valid']);
|
|
$this->assertEmpty($result['errors']);
|
|
|
|
// Invalid product data
|
|
$invalidProduct = [
|
|
'name' => '', // Empty name
|
|
'reference' => '', // Empty reference
|
|
'price' => -10, // Negative price
|
|
'category_id' => 'invalid', // Non-numeric category
|
|
'has_stock' => 'yes', // Invalid boolean
|
|
'stock' => -5 // Negative stock
|
|
];
|
|
|
|
$result = $this->validationService->validateProductData($invalidProduct);
|
|
|
|
$this->assertFalse($result['valid']);
|
|
$this->assertNotEmpty($result['errors']);
|
|
$this->assertArrayHasKey('name', $result['errors']);
|
|
$this->assertArrayHasKey('price', $result['errors']);
|
|
$this->assertArrayHasKey('stock', $result['errors']);
|
|
}
|
|
|
|
/**
|
|
* Test invoice data validation
|
|
*/
|
|
public function testInvoiceDataValidation(): void
|
|
{
|
|
$validInvoice = [
|
|
'number' => 'INV-2025-001',
|
|
'client_id' => 1,
|
|
'date' => '2025-09-10',
|
|
'due_date' => '2025-10-10',
|
|
'currency' => 'EUR',
|
|
'subtotal' => 100.00,
|
|
'tax' => 23.00,
|
|
'total' => 123.00,
|
|
'status' => 'pending',
|
|
'items' => [
|
|
[
|
|
'product_id' => 1,
|
|
'quantity' => 2,
|
|
'price' => 50.00,
|
|
'discount' => 0
|
|
]
|
|
]
|
|
];
|
|
|
|
$result = $this->validationService->validateInvoiceData($validInvoice);
|
|
|
|
$this->assertTrue($result['valid']);
|
|
$this->assertEmpty($result['errors']);
|
|
|
|
// Invalid invoice data
|
|
$invalidInvoice = [
|
|
'number' => '', // Empty number
|
|
'client_id' => 'invalid', // Non-numeric client ID
|
|
'date' => 'invalid-date', // Invalid date format
|
|
'due_date' => '2025-09-09', // Due date before invoice date
|
|
'currency' => 'XXX', // Invalid currency
|
|
'subtotal' => -100, // Negative amount
|
|
'total' => 50, // Total less than subtotal
|
|
'items' => [] // Empty items
|
|
];
|
|
|
|
$result = $this->validationService->validateInvoiceData($invalidInvoice);
|
|
|
|
$this->assertFalse($result['valid']);
|
|
$this->assertNotEmpty($result['errors']);
|
|
$this->assertArrayHasKey('number', $result['errors']);
|
|
$this->assertArrayHasKey('client_id', $result['errors']);
|
|
$this->assertArrayHasKey('date', $result['errors']);
|
|
$this->assertArrayHasKey('due_date', $result['errors']);
|
|
$this->assertArrayHasKey('items', $result['errors']);
|
|
}
|
|
|
|
/**
|
|
* Test data sanitization rules
|
|
*/
|
|
public function testDataSanitization(): void
|
|
{
|
|
$testCases = [
|
|
// [input, expected_output, field_type]
|
|
[' Test Company ', 'Test Company', 'company_name'],
|
|
['<script>alert("xss")</script>', 'alert("xss")', 'text_field'],
|
|
['user@EXAMPLE.COM', 'user@example.com', 'email'],
|
|
['+351 910 000 000', '+351910000000', 'phone'],
|
|
['PT 123 456 789', 'PT123456789', 'vat'],
|
|
['PRODUCT-001', 'PRODUCT-001', 'reference'],
|
|
[' MULTI SPACE TEXT ', 'MULTI SPACE TEXT', 'text_field']
|
|
];
|
|
|
|
foreach ($testCases as [$input, $expected, $fieldType]) {
|
|
$sanitized = $this->validationService->sanitizeField($input, $fieldType);
|
|
$this->assertEquals(
|
|
$expected,
|
|
$sanitized,
|
|
"Field sanitization failed for type {$fieldType}: '{$input}' should become '{$expected}'"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test business rule validations
|
|
*/
|
|
public function testBusinessRuleValidations(): void
|
|
{
|
|
// Test duplicate VAT validation
|
|
$existingVats = ['123456789', '987654321'];
|
|
$mockVatChecker = Mockery::mock('DeskMoloni\VatChecker');
|
|
$mockVatChecker->shouldReceive('exists')->with('123456789')->andReturn(true);
|
|
$mockVatChecker->shouldReceive('exists')->with('999999999')->andReturn(false);
|
|
|
|
$this->validationService->setVatChecker($mockVatChecker);
|
|
|
|
$this->assertFalse(
|
|
$this->validationService->isUniqueVAT('123456789'),
|
|
'Existing VAT should not be unique'
|
|
);
|
|
|
|
$this->assertTrue(
|
|
$this->validationService->isUniqueVAT('999999999'),
|
|
'New VAT should be unique'
|
|
);
|
|
|
|
// Test invoice number format validation
|
|
$validInvoiceNumbers = [
|
|
'INV-2025-001',
|
|
'FAT-001',
|
|
'2025001',
|
|
'INVOICE-123'
|
|
];
|
|
|
|
foreach ($validInvoiceNumbers as $number) {
|
|
$this->assertTrue(
|
|
$this->validationService->isValidInvoiceNumber($number),
|
|
"Invoice number should be valid: {$number}"
|
|
);
|
|
}
|
|
|
|
$invalidInvoiceNumbers = [
|
|
'', // Empty
|
|
'123', // Too short
|
|
'INV-', // Incomplete
|
|
str_repeat('A', 50) // Too long
|
|
];
|
|
|
|
foreach ($invalidInvoiceNumbers as $number) {
|
|
$this->assertFalse(
|
|
$this->validationService->isValidInvoiceNumber($number),
|
|
"Invoice number should be invalid: {$number}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test field length validations
|
|
*/
|
|
public function testFieldLengthValidations(): void
|
|
{
|
|
$fieldLimits = [
|
|
'company_name' => ['max' => 255, 'min' => 1],
|
|
'email' => ['max' => 320, 'min' => 5],
|
|
'phone' => ['max' => 20, 'min' => 9],
|
|
'address' => ['max' => 500, 'min' => 5],
|
|
'product_name' => ['max' => 255, 'min' => 1],
|
|
'invoice_notes' => ['max' => 1000, 'min' => 0]
|
|
];
|
|
|
|
foreach ($fieldLimits as $fieldType => $limits) {
|
|
// Test minimum length
|
|
if ($limits['min'] > 0) {
|
|
$shortValue = str_repeat('a', $limits['min'] - 1);
|
|
$this->assertFalse(
|
|
$this->validationService->validateFieldLength($shortValue, $fieldType),
|
|
"Field {$fieldType} should reject value shorter than {$limits['min']}"
|
|
);
|
|
}
|
|
|
|
// Test valid length
|
|
$validValue = str_repeat('a', $limits['min']);
|
|
$this->assertTrue(
|
|
$this->validationService->validateFieldLength($validValue, $fieldType),
|
|
"Field {$fieldType} should accept valid length value"
|
|
);
|
|
|
|
// Test maximum length
|
|
$longValue = str_repeat('a', $limits['max'] + 1);
|
|
$this->assertFalse(
|
|
$this->validationService->validateFieldLength($longValue, $fieldType),
|
|
"Field {$fieldType} should reject value longer than {$limits['max']}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test currency and amount validations
|
|
*/
|
|
public function testCurrencyAndAmountValidations(): void
|
|
{
|
|
$validCurrencies = ['EUR', 'USD', 'GBP', 'CHF'];
|
|
$invalidCurrencies = ['', 'EURO', 'XXX', '123'];
|
|
|
|
foreach ($validCurrencies as $currency) {
|
|
$this->assertTrue(
|
|
$this->validationService->isValidCurrency($currency),
|
|
"Currency should be valid: {$currency}"
|
|
);
|
|
}
|
|
|
|
foreach ($invalidCurrencies as $currency) {
|
|
$this->assertFalse(
|
|
$this->validationService->isValidCurrency($currency),
|
|
"Currency should be invalid: {$currency}"
|
|
);
|
|
}
|
|
|
|
// Test amount validations
|
|
$validAmounts = [0, 0.01, 1.00, 999.99, 9999999.99];
|
|
$invalidAmounts = [-1, -0.01, 'invalid', '', null];
|
|
|
|
foreach ($validAmounts as $amount) {
|
|
$this->assertTrue(
|
|
$this->validationService->isValidAmount($amount),
|
|
"Amount should be valid: {$amount}"
|
|
);
|
|
}
|
|
|
|
foreach ($invalidAmounts as $amount) {
|
|
$this->assertFalse(
|
|
$this->validationService->isValidAmount($amount),
|
|
"Amount should be invalid: " . var_export($amount, true)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test date validations
|
|
*/
|
|
public function testDateValidations(): void
|
|
{
|
|
$validDates = [
|
|
'2025-09-10',
|
|
'2025-12-31',
|
|
'2024-02-29', // Leap year
|
|
date('Y-m-d') // Current date
|
|
];
|
|
|
|
foreach ($validDates as $date) {
|
|
$this->assertTrue(
|
|
$this->validationService->isValidDate($date),
|
|
"Date should be valid: {$date}"
|
|
);
|
|
}
|
|
|
|
$invalidDates = [
|
|
'', // Empty
|
|
'invalid-date',
|
|
'2025-13-01', // Invalid month
|
|
'2025-02-30', // Invalid day
|
|
'2023-02-29', // Not a leap year
|
|
'10/09/2025', // Wrong format
|
|
'2025/09/10' // Wrong format
|
|
];
|
|
|
|
foreach ($invalidDates as $date) {
|
|
$this->assertFalse(
|
|
$this->validationService->isValidDate($date),
|
|
"Date should be invalid: {$date}"
|
|
);
|
|
}
|
|
|
|
// Test date range validation
|
|
$startDate = '2025-09-01';
|
|
$endDate = '2025-09-30';
|
|
|
|
$this->assertTrue(
|
|
$this->validationService->isValidDateRange($startDate, $endDate),
|
|
'Valid date range should be accepted'
|
|
);
|
|
|
|
$this->assertFalse(
|
|
$this->validationService->isValidDateRange($endDate, $startDate),
|
|
'Invalid date range (end before start) should be rejected'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Test composite validation rules
|
|
*/
|
|
public function testCompositeValidationRules(): void
|
|
{
|
|
// Test invoice total calculation validation
|
|
$invoiceData = [
|
|
'subtotal' => 100.00,
|
|
'tax_rate' => 23.0,
|
|
'tax_amount' => 23.00,
|
|
'discount' => 10.00,
|
|
'total' => 113.00
|
|
];
|
|
|
|
$this->assertTrue(
|
|
$this->validationService->validateInvoiceTotals($invoiceData),
|
|
'Correct invoice totals should validate'
|
|
);
|
|
|
|
$invoiceData['total'] = 150.00; // Incorrect total
|
|
|
|
$this->assertFalse(
|
|
$this->validationService->validateInvoiceTotals($invoiceData),
|
|
'Incorrect invoice totals should not validate'
|
|
);
|
|
|
|
// Test client address validation
|
|
$addressData = [
|
|
'street' => 'Rua de Teste, 123',
|
|
'city' => 'Lisboa',
|
|
'zip' => '1000-001',
|
|
'country' => 'Portugal'
|
|
];
|
|
|
|
$this->assertTrue(
|
|
$this->validationService->validateAddress($addressData),
|
|
'Complete address should validate'
|
|
);
|
|
|
|
unset($addressData['city']);
|
|
|
|
$this->assertFalse(
|
|
$this->validationService->validateAddress($addressData),
|
|
'Incomplete address should not validate'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Test validation error message formatting
|
|
*/
|
|
public function testValidationErrorMessageFormatting(): void
|
|
{
|
|
$errors = [
|
|
'company' => 'Company name is required',
|
|
'email' => 'Email format is invalid',
|
|
'vat' => 'VAT number must be 9 digits'
|
|
];
|
|
|
|
$formatted = $this->validationService->formatValidationErrors($errors);
|
|
|
|
$this->assertIsArray($formatted);
|
|
$this->assertCount(3, $formatted);
|
|
|
|
foreach ($formatted as $error) {
|
|
$this->assertArrayHasKey('field', $error);
|
|
$this->assertArrayHasKey('message', $error);
|
|
$this->assertArrayHasKey('code', $error);
|
|
}
|
|
|
|
// Test localized error messages
|
|
$localizedErrors = $this->validationService->formatValidationErrors($errors, 'pt');
|
|
|
|
$this->assertIsArray($localizedErrors);
|
|
$this->assertNotEquals($formatted, $localizedErrors, 'Localized errors should be different');
|
|
}
|
|
} |