Files
desk-moloni/modules/desk_moloni/tests/MoloniApiContractTest.php
Emanuel Almeida 8c4f68576f chore: add spec-kit and standardize signatures
- 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>
2025-09-12 01:27:37 +01:00

776 lines
27 KiB
PHP

/**
* Descomplicar® Crescimento Digital
* https://descomplicar.pt
*/
<?php
defined('BASEPATH') or exit('No direct script access allowed');
/**
* Moloni API Contract Tests
*
* Verifies that API implementation matches the OpenAPI specification
* Tests all endpoints defined in moloni-api.yaml
*
* @package DeskMoloni
* @author Descomplicar®
* @copyright 2025 Descomplicar
* @version 3.0.0
*/
class MoloniApiContractTest extends PHPUnit\Framework\TestCase
{
private $CI;
private $api_client;
private $contract_spec;
private $test_company_id;
protected function setUp(): void
{
// Get CodeIgniter instance
$this->CI = &get_instance();
// Load API client
$this->CI->load->library('desk_moloni/moloniapiclient');
$this->api_client = $this->CI->moloniapiclient;
// Load contract specification
$this->loadContractSpec();
// Test company ID
$this->test_company_id = 12345;
// Set up authentication for contract tests
$this->setupContractAuth();
}
/**
* Load OpenAPI contract specification
*/
private function loadContractSpec()
{
$spec_file = FCPATH . '../specs/001-desk-moloni-integration/contracts/moloni-api.yaml';
if (file_exists($spec_file)) {
$this->contract_spec = yaml_parse_file($spec_file);
} else {
// Fallback to embedded spec for testing
$this->contract_spec = $this->getEmbeddedSpec();
}
}
/**
* Set up authentication for contract testing
*/
private function setupContractAuth()
{
$this->CI->load->library('desk_moloni/molonioauth');
$this->CI->load->library('desk_moloni/tokenmanager');
// Configure OAuth
$this->CI->molonioauth->configure('test_client_id', 'test_client_secret');
// Add mock tokens
$this->CI->tokenmanager->save_tokens([
'access_token' => 'contract_test_token',
'expires_in' => 3600
]);
}
/**
* Test OAuth 2.0 Token Exchange endpoint
* POST /oauth2/token
*/
public function testOAuthTokenExchangeContract()
{
$endpoint_spec = $this->getEndpointSpec('post', '/oauth2/token');
// Test authorization_code grant
$auth_code_data = [
'grant_type' => 'authorization_code',
'code' => 'test_auth_code',
'client_id' => 'test_client_id',
'client_secret' => 'test_client_secret',
'redirect_uri' => 'https://test.com/callback'
];
$this->validateRequestSchema($auth_code_data, $endpoint_spec['requestBody']);
// Test refresh_token grant
$refresh_token_data = [
'grant_type' => 'refresh_token',
'refresh_token' => 'test_refresh_token',
'client_id' => 'test_client_id',
'client_secret' => 'test_client_secret'
];
$this->validateRequestSchema($refresh_token_data, $endpoint_spec['requestBody']);
// Validate response schema
$mock_response = [
'access_token' => 'access_token_value',
'token_type' => 'Bearer',
'expires_in' => 3600,
'refresh_token' => 'refresh_token_value',
'scope' => 'read write'
];
$this->validateResponseSchema($mock_response, $endpoint_spec['responses']['200']);
}
/**
* Test Customers List endpoint
* GET /customers
*/
public function testCustomersListContract()
{
$endpoint_spec = $this->getEndpointSpec('get', '/customers');
// Test required parameters
$params = [
'company_id' => $this->test_company_id,
'qty' => 50,
'offset' => 0
];
$this->validateQueryParameters($params, $endpoint_spec['parameters']);
// Validate response structure
$mock_customers = [
[
'customer_id' => 1,
'number' => 'CUST001',
'name' => 'Test Customer',
'vat' => '123456789',
'email' => 'test@example.com',
'phone' => '+351912345678',
'address' => 'Test Address',
'zip_code' => '4000-000',
'city' => 'Porto',
'country_id' => 1,
'website' => 'https://example.com',
'notes' => 'Test notes'
]
];
$this->validateResponseSchema($mock_customers, $endpoint_spec['responses']['200']);
}
/**
* Test Customer Creation endpoint
* POST /customers
*/
public function testCustomerCreateContract()
{
$endpoint_spec = $this->getEndpointSpec('post', '/customers');
// Test valid customer creation data
$customer_data = [
'company_id' => $this->test_company_id,
'name' => 'New Test Customer',
'vat' => '987654321',
'email' => 'newcustomer@example.com',
'phone' => '+351987654321',
'address' => 'New Address',
'zip_code' => '1000-000',
'city' => 'Lisboa',
'country_id' => 1,
'website' => 'https://newcustomer.com',
'notes' => 'New customer notes'
];
$this->validateRequestSchema($customer_data, $endpoint_spec['requestBody']);
// Test required fields validation
$required_fields = ['company_id', 'name', 'vat'];
foreach ($required_fields as $field) {
$this->assertArrayHasKey($field, $customer_data, "Required field '{$field}' missing");
}
// Test response schema
$mock_response = $customer_data;
$mock_response['customer_id'] = 123;
$mock_response['number'] = 'CUST123';
$this->validateResponseSchema($mock_response, $endpoint_spec['responses']['201']);
}
/**
* Test Customer Get endpoint
* GET /customers/{customer_id}
*/
public function testCustomerGetContract()
{
$endpoint_spec = $this->getEndpointSpec('get', '/customers/{customer_id}');
// Test path parameters
$customer_id = 123;
$this->assertIsInt($customer_id);
// Test query parameters
$params = [
'company_id' => $this->test_company_id
];
$this->validateQueryParameters($params, $endpoint_spec['parameters']);
// Test response schema
$mock_customer = [
'customer_id' => $customer_id,
'number' => 'CUST123',
'name' => 'Retrieved Customer',
'vat' => '123456789',
'email' => 'customer@example.com',
'phone' => '+351912345678',
'address' => 'Customer Address',
'zip_code' => '4000-000',
'city' => 'Porto',
'country_id' => 1,
'website' => 'https://customer.com',
'notes' => 'Customer notes'
];
$this->validateResponseSchema($mock_customer, $endpoint_spec['responses']['200']);
}
/**
* Test Customer Update endpoint
* PUT /customers/{customer_id}
*/
public function testCustomerUpdateContract()
{
$endpoint_spec = $this->getEndpointSpec('put', '/customers/{customer_id}');
// Test update data
$update_data = [
'customer_id' => 123,
'company_id' => $this->test_company_id,
'name' => 'Updated Customer Name',
'email' => 'updated@example.com',
'phone' => '+351999888777',
'address' => 'Updated Address',
'city' => 'Braga',
'notes' => 'Updated notes'
];
$this->validateRequestSchema($update_data, $endpoint_spec['requestBody']);
// Test required fields for update
$required_fields = ['customer_id', 'company_id'];
foreach ($required_fields as $field) {
$this->assertArrayHasKey($field, $update_data, "Required field '{$field}' missing");
}
// Test response schema
$this->validateResponseSchema($update_data, $endpoint_spec['responses']['200']);
}
/**
* Test Products List endpoint
* GET /products
*/
public function testProductsListContract()
{
$endpoint_spec = $this->getEndpointSpec('get', '/products');
// Test parameters
$params = [
'company_id' => $this->test_company_id
];
$this->validateQueryParameters($params, $endpoint_spec['parameters']);
// Test response schema
$mock_products = [
[
'product_id' => 1,
'name' => 'Test Product',
'summary' => 'Product description',
'reference' => 'PROD001',
'price' => 99.99,
'unit_id' => 1,
'has_stock' => 0,
'stock' => 0.0,
'minimum_stock' => 0.0
]
];
$this->validateResponseSchema($mock_products, $endpoint_spec['responses']['200']);
}
/**
* Test Product Creation endpoint
* POST /products
*/
public function testProductCreateContract()
{
$endpoint_spec = $this->getEndpointSpec('post', '/products');
// Test product creation data
$product_data = [
'company_id' => $this->test_company_id,
'name' => 'New Product',
'summary' => 'New product description',
'reference' => 'NEWPROD001',
'price' => 149.99,
'unit_id' => 1,
'has_stock' => 0
];
$this->validateRequestSchema($product_data, $endpoint_spec['requestBody']);
// Test required fields
$required_fields = ['company_id', 'name', 'price'];
foreach ($required_fields as $field) {
$this->assertArrayHasKey($field, $product_data, "Required field '{$field}' missing");
}
// Test price is numeric
$this->assertIsNumeric($product_data['price']);
// Test response schema
$mock_response = $product_data;
$mock_response['product_id'] = 456;
$this->validateResponseSchema($mock_response, $endpoint_spec['responses']['201']);
}
/**
* Test Invoice Creation endpoint
* POST /invoices
*/
public function testInvoiceCreateContract()
{
$endpoint_spec = $this->getEndpointSpec('post', '/invoices');
// Test invoice creation data
$invoice_data = [
'company_id' => $this->test_company_id,
'customer_id' => 123,
'date' => date('Y-m-d'),
'expiration_date' => date('Y-m-d', strtotime('+30 days')),
'document_set_id' => 1,
'products' => [
[
'product_id' => 1,
'name' => 'Invoice Product',
'summary' => 'Product for invoice',
'qty' => 2.0,
'price' => 50.0,
'discount' => 0.0,
'tax' => 23.0
]
],
'notes' => 'Invoice notes'
];
$this->validateRequestSchema($invoice_data, $endpoint_spec['requestBody']);
// Test required fields
$required_fields = ['company_id', 'customer_id', 'date', 'products'];
foreach ($required_fields as $field) {
$this->assertArrayHasKey($field, $invoice_data, "Required field '{$field}' missing");
}
// Test products array
$this->assertIsArray($invoice_data['products']);
$this->assertNotEmpty($invoice_data['products']);
// Test product structure
$product = $invoice_data['products'][0];
$product_required_fields = ['product_id', 'name', 'qty', 'price'];
foreach ($product_required_fields as $field) {
$this->assertArrayHasKey($field, $product, "Product required field '{$field}' missing");
}
// Test response schema
$mock_response = [
'document_id' => 789,
'number' => 'INV001/2025',
'date' => $invoice_data['date'],
'customer_id' => $invoice_data['customer_id'],
'net_value' => 100.0,
'tax_value' => 23.0,
'gross_value' => 123.0,
'status' => 1,
'products' => [
[
'product_id' => 1,
'name' => 'Invoice Product',
'summary' => 'Product for invoice',
'qty' => 2.0,
'price' => 50.0,
'discount' => 0.0,
'tax' => 23.0
]
]
];
$this->validateResponseSchema($mock_response, $endpoint_spec['responses']['201']);
}
/**
* Test Invoice PDF endpoint
* GET /invoices/{invoice_id}/getPDF
*/
public function testInvoicePdfContract()
{
$endpoint_spec = $this->getEndpointSpec('get', '/invoices/{invoice_id}/getPDF');
// Test path parameters
$invoice_id = 789;
$this->assertIsInt($invoice_id);
// Test query parameters
$params = [
'company_id' => $this->test_company_id
];
$this->validateQueryParameters($params, $endpoint_spec['parameters']);
// For PDF response, we test that it would return binary data
// In actual implementation, this would be validated differently
$this->assertTrue(true, 'PDF endpoint contract structure verified');
}
/**
* Test API base URL and versioning
*/
public function testApiBaseUrlContract()
{
$expected_base_url = 'https://api.moloni.pt/v1';
$spec_servers = $this->contract_spec['servers'];
$this->assertNotEmpty($spec_servers);
$this->assertEquals($expected_base_url, $spec_servers[0]['url']);
}
/**
* Test OAuth 2.0 security scheme
*/
public function testOAuth2SecurityScheme()
{
$security_schemes = $this->contract_spec['components']['securitySchemes'];
$this->assertArrayHasKey('oauth2', $security_schemes);
$oauth2_scheme = $security_schemes['oauth2'];
$this->assertEquals('oauth2', $oauth2_scheme['type']);
$this->assertArrayHasKey('flows', $oauth2_scheme);
$this->assertArrayHasKey('authorizationCode', $oauth2_scheme['flows']);
$auth_code_flow = $oauth2_scheme['flows']['authorizationCode'];
$this->assertEquals('https://api.moloni.pt/v1/oauth2/authorize', $auth_code_flow['authorizationUrl']);
$this->assertEquals('https://api.moloni.pt/v1/oauth2/token', $auth_code_flow['tokenUrl']);
}
/**
* Test all schema definitions exist
*/
public function testSchemaDefinitions()
{
$schemas = $this->contract_spec['components']['schemas'];
$required_schemas = [
'TokenResponse',
'Customer', 'CustomerCreate', 'CustomerUpdate',
'Product', 'ProductCreate',
'Invoice', 'InvoiceCreate',
'InvoiceProduct', 'InvoiceProductCreate'
];
foreach ($required_schemas as $schema) {
$this->assertArrayHasKey($schema, $schemas, "Schema '{$schema}' not defined");
$this->assertArrayHasKey('type', $schemas[$schema], "Schema '{$schema}' missing type");
$this->assertArrayHasKey('properties', $schemas[$schema], "Schema '{$schema}' missing properties");
}
}
/**
* Test API client implementation matches contract
*/
public function testApiClientMethodsMatchContract()
{
$paths = $this->contract_spec['paths'];
// Verify API client has methods for all endpoints
$this->assertTrue(method_exists($this->api_client, 'list_customers'));
$this->assertTrue(method_exists($this->api_client, 'get_customer'));
$this->assertTrue(method_exists($this->api_client, 'create_customer'));
$this->assertTrue(method_exists($this->api_client, 'update_customer'));
$this->assertTrue(method_exists($this->api_client, 'list_products'));
$this->assertTrue(method_exists($this->api_client, 'create_product'));
$this->assertTrue(method_exists($this->api_client, 'create_invoice'));
$this->assertTrue(method_exists($this->api_client, 'get_invoice_pdf'));
}
/**
* Validate request data against schema
*/
private function validateRequestSchema($data, $request_body_spec)
{
if (!isset($request_body_spec['content']['application/json']['schema'])) {
return; // No schema to validate against
}
$schema_ref = $request_body_spec['content']['application/json']['schema']['$ref'] ?? null;
if ($schema_ref) {
$schema_name = str_replace('#/components/schemas/', '', $schema_ref);
$schema = $this->contract_spec['components']['schemas'][$schema_name];
$this->validateDataAgainstSchema($data, $schema);
}
}
/**
* Validate response data against schema
*/
private function validateResponseSchema($data, $response_spec)
{
if (!isset($response_spec['content']['application/json']['schema'])) {
return; // No schema to validate against
}
$schema = $response_spec['content']['application/json']['schema'];
if (isset($schema['type']) && $schema['type'] === 'array') {
$this->assertIsArray($data);
if (isset($schema['items']['$ref'])) {
$item_schema_name = str_replace('#/components/schemas/', '', $schema['items']['$ref']);
$item_schema = $this->contract_spec['components']['schemas'][$item_schema_name];
if (!empty($data)) {
$this->validateDataAgainstSchema($data[0], $item_schema);
}
}
} elseif (isset($schema['$ref'])) {
$schema_name = str_replace('#/components/schemas/', '', $schema['$ref']);
$schema_def = $this->contract_spec['components']['schemas'][$schema_name];
$this->validateDataAgainstSchema($data, $schema_def);
}
}
/**
* Validate query parameters
*/
private function validateQueryParameters($params, $parameters_spec)
{
foreach ($parameters_spec as $param_spec) {
if ($param_spec['in'] === 'query' && isset($param_spec['required']) && $param_spec['required']) {
$param_name = $param_spec['name'];
$this->assertArrayHasKey($param_name, $params, "Required query parameter '{$param_name}' missing");
}
}
}
/**
* Validate data against schema definition
*/
private function validateDataAgainstSchema($data, $schema)
{
$this->assertIsArray($data);
if (isset($schema['required'])) {
foreach ($schema['required'] as $required_field) {
$this->assertArrayHasKey($required_field, $data, "Required field '{$required_field}' missing");
}
}
if (isset($schema['properties'])) {
foreach ($data as $field => $value) {
if (isset($schema['properties'][$field])) {
$field_schema = $schema['properties'][$field];
$this->validateFieldType($value, $field_schema, $field);
}
}
}
}
/**
* Validate field type against schema
*/
private function validateFieldType($value, $field_schema, $field_name)
{
if (!isset($field_schema['type'])) {
return; // No type constraint
}
switch ($field_schema['type']) {
case 'string':
$this->assertIsString($value, "Field '{$field_name}' should be string");
break;
case 'integer':
$this->assertIsInt($value, "Field '{$field_name}' should be integer");
break;
case 'number':
$this->assertIsNumeric($value, "Field '{$field_name}' should be numeric");
break;
case 'array':
$this->assertIsArray($value, "Field '{$field_name}' should be array");
break;
}
// Validate format if specified
if (isset($field_schema['format'])) {
switch ($field_schema['format']) {
case 'date':
$this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', $value, "Field '{$field_name}' should be valid date");
break;
case 'email':
$this->assertFilter($value, FILTER_VALIDATE_EMAIL, "Field '{$field_name}' should be valid email");
break;
}
}
}
/**
* Get endpoint specification from contract
*/
private function getEndpointSpec($method, $path)
{
$paths = $this->contract_spec['paths'];
$this->assertArrayHasKey($path, $paths, "Endpoint '{$path}' not found in contract");
$this->assertArrayHasKey($method, $paths[$path], "Method '{$method}' not found for endpoint '{$path}'");
return $paths[$path][$method];
}
/**
* Get embedded specification for testing when file is not available
*/
private function getEmbeddedSpec()
{
return [
'openapi' => '3.0.3',
'info' => [
'title' => 'Moloni API Integration Contract',
'version' => '3.0.0'
],
'servers' => [
['url' => 'https://api.moloni.pt/v1']
],
'paths' => [
'/oauth2/token' => [
'post' => [
'operationId' => 'exchangeToken',
'requestBody' => [
'content' => [
'application/x-www-form-urlencoded' => [
'schema' => [
'type' => 'object',
'properties' => [
'grant_type' => ['type' => 'string'],
'code' => ['type' => 'string'],
'client_id' => ['type' => 'string'],
'client_secret' => ['type' => 'string']
]
]
]
]
],
'responses' => [
'200' => [
'content' => [
'application/json' => [
'schema' => ['$ref' => '#/components/schemas/TokenResponse']
]
]
]
]
]
],
'/customers' => [
'get' => [
'operationId' => 'listCustomers',
'parameters' => [
[
'name' => 'company_id',
'in' => 'query',
'required' => true,
'schema' => ['type' => 'integer']
]
],
'responses' => [
'200' => [
'content' => [
'application/json' => [
'schema' => [
'type' => 'array',
'items' => ['$ref' => '#/components/schemas/Customer']
]
]
]
]
]
],
'post' => [
'operationId' => 'createCustomer',
'requestBody' => [
'content' => [
'application/json' => [
'schema' => ['$ref' => '#/components/schemas/CustomerCreate']
]
]
],
'responses' => [
'201' => [
'content' => [
'application/json' => [
'schema' => ['$ref' => '#/components/schemas/Customer']
]
]
]
]
]
]
// Additional endpoints would be defined here...
],
'components' => [
'securitySchemes' => [
'oauth2' => [
'type' => 'oauth2',
'flows' => [
'authorizationCode' => [
'authorizationUrl' => 'https://api.moloni.pt/v1/oauth2/authorize',
'tokenUrl' => 'https://api.moloni.pt/v1/oauth2/token'
]
]
]
],
'schemas' => [
'TokenResponse' => [
'type' => 'object',
'properties' => [
'access_token' => ['type' => 'string'],
'token_type' => ['type' => 'string'],
'expires_in' => ['type' => 'integer'],
'refresh_token' => ['type' => 'string']
]
],
'Customer' => [
'type' => 'object',
'properties' => [
'customer_id' => ['type' => 'integer'],
'name' => ['type' => 'string'],
'vat' => ['type' => 'string'],
'email' => ['type' => 'string']
]
],
'CustomerCreate' => [
'type' => 'object',
'required' => ['company_id', 'name', 'vat'],
'properties' => [
'company_id' => ['type' => 'integer'],
'name' => ['type' => 'string'],
'vat' => ['type' => 'string'],
'email' => ['type' => 'string']
]
]
]
]
];
}
}