Ga naar hoofdinhoud

Testing

OpenRegister uses a comprehensive testing stack to ensure code quality, type safety, and functionality across all components.

Testing Stack​

OpenRegister employs multiple testing and analysis tools:

Static Analysis Tools​

Psalm​

Type-safe static analysis for PHP

Psalm performs deep static analysis to catch type errors, undefined methods, and logical issues before runtime.

Running Psalm:

# From workspace root
cd openregister
composer psalm

# Or using vendor binary
./vendor/bin/psalm --threads=1 --no-cache

# Check specific file
./vendor/bin/psalm --threads=1 --no-cache lib/Service/ObjectService.php

Configuration:

  • Location: openregister/psalm.xml
  • Level: Baseline with incremental improvements
  • Excludes: Migration files

Common Psalm Fixes:

  • Add proper type hints to method parameters
  • Update docblock return types to match actual return types
  • Use dependency injection instead of static service locators
  • Add null checks for nullable properties

PHPCS vs Psalm Conflicts: PHPCS requires explicit boolean comparisons (=== true / === false) via the Squiz.Operators.ComparisonOperatorUsage rule, but Psalm flags these as redundant when functions already return booleans. We suppress these specific cases with inline annotations:

/** @psalm-suppress RedundantCondition - PHPCS requires explicit comparison (Squiz.Operators.ComparisonOperatorUsage) */
if (is_array($order) === true) {
// PHPCS requires === true, Psalm flags as redundant
}

This approach:

  • Maintains PHPCS compliance (explicit comparisons)
  • Keeps Psalm checking other redundant conditions
  • Documents the conflict clearly in code

PHPStan​

PHP Static Analysis Tool

PHPStan provides additional static analysis with different rules and detection capabilities than Psalm.

Running PHPStan:

# From workspace root
cd openregister
composer phpstan

# Or using vendor binary
./vendor/bin/phpstan analyse

# Check specific file
./vendor/bin/phpstan analyse lib/Service/ObjectService.php

Configuration:

  • Location: openregister/phpstan.neon
  • Level: 5
  • Paths: lib/ directory
  • Excludes: lib/Migration/*

Common PHPStan Fixes:

  • Correct parameter type mismatches
  • Fix undefined method calls
  • Handle null safety properly
  • Add proper return type declarations

PHPMetrics​

Code quality and complexity metrics

PHPMetrics provides comprehensive metrics and visualizations about your codebase, including complexity, maintainability, and code quality indicators.

Running PHPMetrics:

# Generate HTML report
composer phpmetrics

# Generate JSON report
composer phpmetrics:json

# Generate CSV report
composer phpmetrics:csv

# Generate violations XML
composer phpmetrics:violations

# Or using vendor binary directly
./vendor/bin/phpmetrics --report-html=phpmetrics lib/

Output:

  • HTML report in 'phpmetrics' directory with visualizations
  • Interactive charts for complexity, coupling, and OOP metrics
  • Detailed per-class and per-method analysis

Key Metrics Provided:

  • Lines of Code: Physical and logical lines of code
  • Complexity: Cyclomatic complexity, Weighted Method Count
  • Coupling: Afferent/Efferent coupling, Instability, Depth of Inheritance
  • Object-Oriented Metrics: Classes, methods, interfaces, cohesion
  • Quality Indicators: Bugs prediction, maintainability index
  • Violations: Critical errors, warnings, and information notices

Understanding the Report:

  • Complexity Charts: Identify overly complex classes/methods that need refactoring
  • Coupling Graph: Visualize dependencies between packages and classes
  • Maintainability Index: Higher is better; indicates ease of maintaining code
  • Violations Tab: Lists specific code quality issues with severity levels

Configuration: PHPMetrics requires no configuration file. It analyzes all PHP files in the specified directory ('lib/') and generates comprehensive reports automatically.

Note: The 'phpmetrics' directory is excluded from version control (.gitignore).

PHP CodeSniffer (phpcs)​

Coding standards enforcement

PHP CodeSniffer checks code against defined coding standards (PSR-12, Nextcloud standards).

Running phpcs:

# Check all files
./vendor/bin/phpcs --standard=phpcs.xml

# Check specific file
./vendor/bin/phpcs --standard=phpcs.xml lib/Service/ObjectService.php

# Auto-fix issues (where possible)
./vendor/bin/phpcbf --standard=phpcs.xml lib/Service/ObjectService.php

Configuration:

  • Location: openregister/phpcs.xml
  • Standards: PSR-12, Nextcloud coding standards
  • Enforces: Naming conventions, formatting, documentation
  • Custom Sniff: Detects positional arguments after named arguments (PHP 8+ fatal error)

Common phpcs Requirements:

  • Add docblocks to all classes, methods, and properties
  • Add return types to all methods
  • Add type hints to all method parameters
  • Use proper indentation and spacing
  • Follow PSR-12 naming conventions
  • Add inline comments ending with proper punctuation (full-stop, exclamation, question mark)

Named Arguments Check: The custom PHPCS sniff automatically detects PHP 8+ fatal errors where positional arguments are used after named arguments:

// ❌ ERROR: Positional argument after named argument
return new JSONResponse(
data: ['success' => true],
200 // This will cause a PHP 8+ fatal error
);

// ✅ CORRECT: All arguments after named ones must also be named
return new JSONResponse(
data: ['success' => true],
statusCode: 200
);

Named Arguments Checker​

Custom script to detect PHP 8+ named argument errors

A dedicated script checks for positional arguments after named arguments, which causes fatal errors in PHP 8+.

Running the checker:

# Check all files in lib/
composer check:named-args

# Or directly
php scripts/check-named-arguments.php --path=lib

# Check specific directory
php scripts/check-named-arguments.php --path=lib/Controller

What it checks:

  • Detects positional arguments used after named arguments
  • Reports exact line numbers and function calls
  • Provides clear error messages explaining the issue

Example output:

Checking for positional arguments after named arguments in: lib
======================================================================

lib/Controller/SettingsController.php:
Line 4216: Positional argument after named argument (PHP 8+ fatal error).
First named parameter found at line 4212, positional argument at line 4216.
All arguments after the first named argument must also be named.

✗ Found 1 error(s).

Integration: This check is automatically included in:

  • composer check - Standard quality checks
  • composer check:full - Full quality checks
  • composer check:strict - Strict quality checks

Why this matters: In PHP 8+, mixing positional and named arguments causes a fatal error at runtime:

PHP Fatal error: Cannot use positional argument after named argument

This check catches these errors before deployment, preventing 500 errors in production.

Integration Testing​

PHPUnit integration tests verify functionality across components.

Workflow Integration​

All testing tools work together in the development workflow:

  1. During Development: Run phpcs and Psalm/PHPStan frequently to catch issues early
  2. Before Commit: Ensure all static analysis tools pass with zero errors
  3. In CI/CD: Automated checks run on all pull requests
  4. Pre-Release: Full integration test suite runs
  5. Periodic Review: Generate PHPMetrics reports to identify refactoring opportunities

Quick Check All Tools:

# Run all static analysis tools
cd openregister
composer psalm && composer phpstan && ./vendor/bin/phpcs --standard=phpcs.xml

# Generate metrics report for code quality review
composer phpmetrics

Integration Test Strategy​

Overview​

Integration tests verify that different parts of the system work together correctly by testing against a running Docker environment with:

  • Nextcloud container
  • MySQL database
  • Real HTTP requests
  • Actual file storage

Location​

Integration tests are located in:

openregister/tests/Integration/

Core Integration Tests​

The CoreIntegrationTest.php file contains organized test groups covering all major OpenRegister functionality.

Test Group 1: File Upload Tests (Tests 1-15)​

Tests for file attachment and upload functionality.

Covered Functionality​

Single File Uploads

  • Multipart form upload
  • Base64 data URI upload
  • URL reference upload (external files)

Multiple File Uploads

  • Multiple files in single request
  • File arrays (images[])

Validation

  • MIME type validation
  • File size limits
  • Corrupted base64 detection

File Operations

  • Retrieving file metadata
  • Updating files
  • Mixed upload methods

Example Test​

public function testMultipartUploadSinglePdf(): void
{
$pdfContent = '%PDF-1.4 fake pdf content for testing';
$tmpFile = tmpfile();
fwrite($tmpFile, $pdfContent);

$response = $this->client->post(
"/index.php/apps/openregister/api/objects/{$register}/{$schema}",
[
'multipart' => [
['name' => 'title', 'contents' => 'Test Document'],
['name' => 'attachment', 'contents' => fopen($tmpPath, 'r'),
'filename' => 'test.pdf',
'headers' => ['Content-Type' => 'application/pdf']
],
]
]
);

$this->assertEquals(201, $response->getStatusCode());
}

Test Group 2: Cascade Protection Tests (Tests 16-18)​

Tests for referential integrity and cascade protection.

Covered Functionality​

  • Register Protection: Cannot delete register with objects
  • Schema Protection: Cannot delete schema with objects
  • Cleanup Workflow: Can delete after proper cleanup

Why This Matters​

Cascade protection prevents accidental data loss by ensuring:

  1. Registers cannot be deleted while containing schemas or objects
  2. Schemas cannot be deleted while containing objects
  3. Proper cleanup order is enforced (objects → schemas → registers)

Example Test​

public function testCannotDeleteRegisterWithObjects(): void
{
// Create object
$response = $this->client->post(
"/index.php/apps/openregister/api/objects/{$register}/{$schema}",
['json' => ['title' => 'Protection Test']]
);

// Attempt to delete register
$deleteResponse = $this->client->delete(
"/index.php/apps/openregister/api/registers/{$registerId}"
);

// Should fail with 400 or 409
$this->assertContains($deleteResponse->getStatusCode(), [400, 409]);
}

Test Group 3: File Publishing Tests (Tests 19-22)​

Tests for file sharing, publishing, and metadata.

Covered Functionality​

File Access Control

  • Authenticated URLs for non-shared files (/api/files/)
  • Public share URLs for published files (/index.php/s/)

Auto-Publishing

  • Schema-level autoPublish configuration
  • Automatic share creation on upload

Metadata Integration

  • Logo field mapping (objectImageField)
  • Image metadata in @self.image
  • First-file-in-array selection

File Deletion

  • Delete single file by sending null
  • Delete file array by sending []

Example Test​

public function testAutoShareFileProperty(): void
{
// Create schema with autoPublish
$schemaResponse = $this->client->post(
'/index.php/apps/openregister/api/schemas',
[
'json' => [
'register' => $registerId,
'slug' => 'auto-share-test',
'properties' => [
'document' => [
'type' => 'file',
'autoPublish' => true
],
],
]
]
);

// Upload file
$response = $this->client->post(
"/index.php/apps/openregister/api/objects/{$register}/{$schema}",
['multipart' => [...]]
);

$object = json_decode($response->getBody(), true);

// Verify public share URL
$this->assertArrayHasKey('published', $object['document']);
$this->assertStringContainsString(
'/index.php/s/',
$object['document']['accessUrl']
);
}

Test Group 4: Array Filtering Tests (Tests 23-30)​

Tests for advanced filtering with AND/OR logic and dot notation.

What These Tests Cover​

These tests verify filtering functionality across multiple registers and schemas, as well as array properties within objects:

Cross-Register/Schema Filtering

  • Test objects created in different registers (Register 1, Register 2)
  • Test objects created with different schemas (Schema 1, Schema 2)
  • Verify AND logic returns zero results when filtering single-value fields for multiple values
  • Verify OR logic returns objects from multiple registers/schemas

Object Array Property Filtering

  • Test objects with array properties (e.g., 'availableColours': ['red', 'blue'])
  • Verify AND logic requires ALL values present in the array
  • Verify OR logic matches objects with ANY of the specified values

Covered Functionality​

Default AND Logic

  • Metadata arrays: @self.register[]=1&@self.register[]=2 → zero results (object can't be in BOTH registers)
  • Object arrays: colours[]=red&colours[]=blue → objects with BOTH colors in their array

Explicit OR Logic

  • Metadata: @self.register[or]=1,2 → objects from EITHER register 1 OR register 2
  • Objects: colours[or]=red,blue → objects with EITHER red OR blue (or both)

Dot Notation Syntax

  • Clean URLs: @self.field instead of @self[field]
  • Works with operators: @self.created[gte]=2025-01-01
  • Combines with regular filters: @self.register=5&title=Test

Complex Scenarios

  • Multiple filter types combined
  • Nested operators
  • Mixed AND/OR logic across different fields

Example Tests​

Default AND Logic - Cross-Register Testing

public function testMetadataArrayFilterDefaultAndLogic(): void
{
// Step 1: Create TWO separate registers
$register1 = $this->client->post('/api/registers', [
'json' => ['slug' => 'filter-test-1', 'title' => 'Filter Test Register 1']
]);
$register2 = $this->client->post('/api/registers', [
'json' => ['slug' => 'filter-test-2', 'title' => 'Filter Test Register 2']
]);

// Step 2: Create schemas in EACH register
$schema1 = createSchemaInRegister($register1['id']);
$schema2 = createSchemaInRegister($register2['id']);

// Step 3: Create objects in DIFFERENT registers
$obj1 = createObjectInRegister($register1, $schema1); // In Register 1
$obj2 = createObjectInRegister($register2, $schema2); // In Register 2

// Step 4: Filter with AND logic (default) - search across ALL registers
$url = "/api/objects?@self.register[]={$reg1['id']}&@self.register[]={$reg2['id']}";
$response = $this->client->get($url);
$result = json_decode($response->getBody(), true);

// Step 5: Verify zero results (object can't be in BOTH registers simultaneously)
$this->assertEquals(0, $result['total']);
}

Explicit OR Logic - Cross-Register Testing

public function testMetadataArrayFilterExplicitOrLogicWithDotNotation(): void
{
// Step 1: Create TWO separate registers
$register1 = createRegister('or-test-1');
$register2 = createRegister('or-test-2');

// Step 2: Create schemas in EACH register
$schema1 = createSchemaInRegister($register1['id']);
$schema2 = createSchemaInRegister($register2['id']);

// Step 3: Create objects in DIFFERENT registers
$obj1 = createObjectInRegister($register1, $schema1); // In Register 1
$obj2 = createObjectInRegister($register2, $schema2); // In Register 2

// Step 4: Filter with OR logic using dot notation - search across ALL registers
$url = "/api/objects?@self.register[or]={$reg1['id']},{$reg2['id']}";
$response = $this->client->get($url);
$result = json_decode($response->getBody(), true);

// Step 5: Verify BOTH objects returned (from register 1 OR register 2)
$this->assertGreaterThanOrEqual(2, $result['total']);
$returnedIds = array_column($result['results'], 'id');
$this->assertContains($obj1['id'], $returnedIds); // From Register 1
$this->assertContains($obj2['id'], $returnedIds); // From Register 2
}

Object Array Property AND Logic - Within Same Register/Schema

public function testObjectArrayPropertyDefaultAndLogic(): void
{
// Step 1: Create ONE register with ONE schema that has array property
$register = createRegister('product-test');
$schema = $this->client->post('/api/schemas', [
'json' => [
'register' => $register['id'],
'slug' => 'product-schema',
'properties' => [
'title' => ['type' => 'string'],
'availableColours' => [
'type' => 'array',
'items' => ['type' => 'string']
]
],
]
]);

// Step 2: Create products with different color combinations IN SAME REGISTER
$redBlue = createProduct(['red', 'blue']); // ✅ Has BOTH red AND blue
$onlyBlue = createProduct(['blue']); // ❌ Has only blue, missing red
$redBlueGreen = createProduct(['red', 'blue', 'green']); // ✅ Has BOTH red AND blue (plus green)

// Step 3: Filter for products with BOTH red AND blue using AND logic
$url = "/api/objects?availableColours[]=red&availableColours[]=blue";
$response = $this->client->get($url);
$result = json_decode($response->getBody(), true);

// Step 4: Verify only products with BOTH colors are returned
$returnedIds = array_column($result['results'], 'id');
$this->assertContains($redBlue['id'], $returnedIds); // Has both
$this->assertContains($redBlueGreen['id'], $returnedIds); // Has both + more
$this->assertNotContains($onlyBlue['id'], $returnedIds); // Missing red
}

Dot Notation Syntax

public function testDotNotationSyntaxForMetadataFilters(): void
{
// Use dot notation for metadata filter
$url = "/api/objects?@self.register={$registerId}";
$response = $this->client->get($url);
$result = json_decode($response->getBody(), true);

// All returned objects should be from specified register
foreach ($result['results'] as $obj) {
$this->assertEquals($registerId, $obj['@self']['register']);
}
}

Running Tests​

Prerequisites​

  1. Docker containers running:

    docker ps | grep -E "nextcloud|database"
  2. OpenRegister app enabled:

    docker exec -u 33 master-nextcloud-1 php occ app:enable openregister

Integration tests should be run inside the Nextcloud Docker container to have access to the full Nextcloud environment:

# Run all tests
docker exec master-nextcloud-1 bash -c "cd /var/www/html/apps-extra/openregister && php vendor/bin/phpunit tests/Integration/CoreIntegrationTest.php --bootstrap tests/integration-bootstrap.php --no-configuration 2>&1"

# Run specific test group with readable output
docker exec master-nextcloud-1 bash -c "cd /var/www/html/apps-extra/openregister && php vendor/bin/phpunit --filter 'testMetadataArrayFilter|testObjectArrayProperty' tests/Integration/CoreIntegrationTest.php --bootstrap tests/integration-bootstrap.php --no-configuration --testdox 2>&1"

# Run single test
docker exec master-nextcloud-1 bash -c "cd /var/www/html/apps-extra/openregister && php vendor/bin/phpunit --filter testDotNotationSyntaxForMetadataFilters tests/Integration/CoreIntegrationTest.php --bootstrap tests/integration-bootstrap.php --no-configuration 2>&1"

# Save output to file
docker exec master-nextcloud-1 bash -c "cd /var/www/html/apps-extra/openregister && php vendor/bin/phpunit tests/Integration/CoreIntegrationTest.php --bootstrap tests/integration-bootstrap.php --no-configuration 2>&1 | tee /tmp/test-output.txt"

Run Specific Test Groups​

# File Upload Tests (1-15)
docker exec master-nextcloud-1 bash -c "cd /var/www/html/apps-extra/openregister && php vendor/bin/phpunit --filter testMultipart tests/Integration/CoreIntegrationTest.php --bootstrap tests/integration-bootstrap.php --no-configuration --testdox"

# Cascade Protection Tests (16-18)
docker exec master-nextcloud-1 bash -c "cd /var/www/html/apps-extra/openregister && php vendor/bin/phpunit --filter testCannotDelete tests/Integration/CoreIntegrationTest.php --bootstrap tests/integration-bootstrap.php --no-configuration --testdox"

# File Publishing Tests (19-22)
docker exec master-nextcloud-1 bash -c "cd /var/www/html/apps-extra/openregister && php vendor/bin/phpunit --filter 'testAutoShare|testLogo|testImage|testDelete' tests/Integration/CoreIntegrationTest.php --bootstrap tests/integration-bootstrap.php --no-configuration --testdox"

# Array Filtering Tests (23-30)
docker exec master-nextcloud-1 bash -c "cd /var/www/html/apps-extra/openregister && php vendor/bin/phpunit --filter 'testMetadataArrayFilter|testObjectArrayProperty|testDotNotation|testComplex' tests/Integration/CoreIntegrationTest.php --bootstrap tests/integration-bootstrap.php --no-configuration --testdox"

Why Run in Docker Container?​

Running tests inside the Docker container ensures:

  • Access to Nextcloud's internal API
  • Proper database connections
  • File system access
  • Nextcloud environment variables
  • PHP extensions and dependencies
  • Correct base URL ('http://localhost' inside container)

Test Environment​

Configuration​

Tests use the following configuration:

  • Base URL: http://localhost
  • Auth: admin:admin (Basic Auth)
  • Containers:
    • master-nextcloud-1 (Nextcloud)
    • master-database-mysql-1 (MySQL)

Cleanup​

Tests automatically clean up created resources:

  1. Objects (deleted first)
  2. Schemas (deleted second)
  3. Registers (deleted last)

Proper cleanup order is essential for cascade protection.

Writing New Tests​

Test Structure​

public function testYourFeature(): void
{
// 1. Setup: Create necessary resources
$register = $this->createTestRegister();
$schema = $this->createTestSchema($register);

// 2. Execute: Perform the test action
$response = $this->client->post(
"/api/objects/{$register}/{$schema}",
['json' => ['title' => 'Test']]
);

// 3. Assert: Verify expected behavior
$this->assertEquals(201, $response->getStatusCode());
$data = json_decode($response->getBody(), true);
$this->assertArrayHasKey('id', $data);

// 4. Cleanup: Track for tearDown
$this->createdObjectIds[] = $data['id'];
}

Best Practices​

  1. Use unique identifiers: Add uniqid() to slugs to avoid conflicts
  2. Track created resources: Add IDs to cleanup arrays
  3. Clear assertions: Use descriptive assertion messages
  4. Isolated tests: Each test should be independent
  5. Cleanup order: Objects → Schemas → Registers

Adding to Test Groups​

When adding tests, follow the existing group structure:

  • Tests 1-15: File uploads
  • Tests 16-18: Cascade protection
  • Tests 19-22: File publishing
  • Tests 23-30: Array filtering
  • Tests 31+: Your new group

Update the class-level docblock when adding new groups.

Continuous Integration​

GitHub Actions​

Tests can be integrated with GitHub Actions:

name: Integration Tests

on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Start Docker containers
run: docker-compose up -d
- name: Run tests
run: ./vendor/bin/phpunit tests/Integration/

Troubleshooting​

Container Connection Issues​

# Check container status
docker ps

# Check container logs
docker logs master-nextcloud-1

# Restart containers
docker-compose restart

Database Issues​

# Access database
docker exec -it master-database-mysql-1 mysql -u nextcloud -pnextcloud nextcloud

# Check object count
SELECT COUNT(*) FROM oc_openregister_objects;

# Clean test data
DELETE FROM oc_openregister_objects WHERE register IN (
SELECT id FROM oc_openregister_registers WHERE slug LIKE 'test-%'
);

Test Failures​

  1. Check container logs for errors
  2. Verify app is enabled: php occ app:list | grep openregister
  3. Check database connections
  4. Ensure proper cleanup between test runs

Test Scope: Cross-Register vs Within-Register​

Understanding test scope is important for debugging and extending tests:

Cross-Register/Schema Tests (Tests 21-22, 25-27)​

These tests create multiple registers and schemas to verify filtering works across different data boundaries:

Purpose: Verify that:

  • OR logic can retrieve objects from multiple registers
  • AND logic correctly returns zero results for single-value fields
  • Dot notation works across register boundaries

Within-Register Tests (Tests 23-24, 28)​

These tests use one register with one schema to verify array property filtering:

Purpose: Verify that:

  • AND logic requires ALL array values to be present
  • OR logic matches ANY array value
  • Array filtering works within object properties

Future Test Groups​

Planned additional test groups:

  • RBAC Tests: Role-based access control
  • Multi-tenancy Tests: Organization isolation
  • Search Tests: Full-text and filtered search
  • Solr Integration Tests: Search engine functionality
  • Performance Tests: Bulk operations and optimization

Best Practices​

Code Quality Standards​

  1. Zero Errors Policy: All code must pass all static analysis tools with zero errors
  2. Warnings Are OK: Warnings are tracked but do not block development
  3. Fix Before Commit: Run all tools before committing changes
  4. Incremental Improvements: Address warnings gradually in separate commits

Static Analysis Workflow​

# 1. Check for errors only
./vendor/bin/phpcs --standard=phpcs.xml --error-severity=1 --warning-severity=0 lib/

# 2. Run Psalm
./vendor/bin/psalm --threads=1 --no-cache

# 3. Run PHPStan
./vendor/bin/phpstan analyse

# 4. If all pass, commit changes
git add .
git commit -m "feat: implement feature X"

Common Patterns​

Dependency Injection vs Static Calls​

Bad (triggers Psalm/PHPStan errors):

$mapper = \OC::$server->get('OCA\OpenRegister\Db\ObjectMapper');

Good (passes all checks):

public function __construct(
private readonly ObjectMapper $objectMapper
) {
}

Nullable Return Types​

Bad:

/**
* @return ValidationError
*/
public function getErrors(): ?ValidationError

Good:

/**
* @return ValidationError|null Returns validation errors or null if none exist.
*/
public function getErrors(): ?ValidationError

Type Casting for Operations​

Bad:

$control += ($character * $multiplier); // String * int triggers error

Good:

$control += ((int) $character * $multiplier); // Explicit cast

See Also​