diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a7c5731..f1b6ff7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,4 +28,7 @@ jobs:
run: composer install --no-interaction --no-progress --prefer-dist
- name: Lint PHP files
- run: find src -name '*.php' -print0 | xargs -0 -n1 php -l
+ run: find src tests -name '*.php' -print0 | xargs -0 -n1 php -l
+
+ - name: Run tests
+ run: vendor/bin/phpunit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..283d9da
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/vendor/
+composer.lock
+.phpunit.result.cache
+.phpunit.cache/
+.DS_Store
diff --git a/composer.json b/composer.json
index 803db28..1237d40 100644
--- a/composer.json
+++ b/composer.json
@@ -3,12 +3,25 @@
"description": "Project management module for the Zerp ERP platform",
"type": "library",
"license": "MIT",
- "require": {},
+ "require": {
+ "php": "^8.2"
+ },
+ "require-dev": {
+ "orchestra/testbench": "^9.0 || ^10.0"
+ },
"autoload": {
"psr-4": {
"Zerp\\Taskly\\": "src/"
}
},
+ "autoload-dev": {
+ "psr-4": {
+ "Zerp\\Taskly\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit"
+ },
"authors": [
{
"name": "ZAWAT OFFICIALS PVT LTD",
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..7be05e5
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ tests
+
+
+
diff --git a/tests/PackageIntegrityTest.php b/tests/PackageIntegrityTest.php
new file mode 100644
index 0000000..7f828f1
--- /dev/null
+++ b/tests/PackageIntegrityTest.php
@@ -0,0 +1,222 @@
+assertIsArray($this->composerJson());
+ }
+
+ public function test_module_json_is_valid_json(): void
+ {
+ $this->assertIsArray($this->moduleJson());
+ }
+
+ /**
+ * PackageSeeder scans vendor/zerp/* for module.json and keys AddOn rows off
+ * it. If package_name drifts from the composer package the module is
+ * installed as, the seeder registers a module nothing can enable.
+ */
+ public function test_module_json_package_name_matches_the_composer_package(): void
+ {
+ $module = $this->moduleJson();
+ $composer = $this->composerJson();
+
+ $this->assertArrayHasKey('package_name', $module);
+ $this->assertSame(
+ $composer['name'],
+ 'zerp/'.$module['package_name'],
+ 'module.json package_name must match the composer package name.'
+ );
+ }
+
+ public function test_module_json_declares_the_fields_the_seeder_reads(): void
+ {
+ $module = $this->moduleJson();
+
+ foreach (['name', 'alias', 'priority', 'version', 'package_name'] as $key) {
+ $this->assertArrayHasKey($key, $module, "module.json is missing '$key'.");
+ }
+
+ $this->assertNotSame('', trim((string) $module['name']));
+ $this->assertNotSame('', trim((string) $module['alias']));
+ $this->assertIsNumeric($module['priority']);
+ }
+
+ /**
+ * Laravel auto-discovers the module through extra.laravel.providers. A
+ * provider renamed or moved without updating composer.json leaves the
+ * module installed but never booted, with nothing logged.
+ */
+ public function test_declared_service_providers_all_exist(): void
+ {
+ $providers = $this->composerJson()['extra']['laravel']['providers'] ?? [];
+
+ $this->assertNotEmpty($providers, 'The package declares no service provider, so Laravel will never boot it.');
+
+ foreach ($providers as $provider) {
+ $this->assertTrue(
+ class_exists($provider),
+ "Declared provider $provider does not exist."
+ );
+ $this->assertTrue(
+ $this->app->providerIsLoaded($provider),
+ "Declared provider $provider did not boot."
+ );
+ }
+ }
+
+ /**
+ * Every class under src/ has to sit at the path its PSR-4 prefix implies,
+ * or Composer's autoloader cannot find it. Checked by reading the declared
+ * namespace rather than by autoloading: module classes extend host app
+ * classes (App\Http\Controllers\Controller, App\Models\User,
+ * App\Models\Concerns\TenantScoped) that do not exist outside an install,
+ * so loading them here would fatal on the parent, not on the mapping.
+ */
+ public function test_every_class_in_src_sits_at_its_psr4_path(): void
+ {
+ $autoload = $this->composerJson()['autoload']['psr-4'] ?? [];
+ $this->assertNotEmpty($autoload, 'The package declares no PSR-4 autoload mapping.');
+
+ $prefix = array_key_first($autoload);
+ $root = realpath(__DIR__.'/../'.$autoload[$prefix]);
+ $this->assertNotFalse($root, "PSR-4 path {$autoload[$prefix]} does not exist.");
+
+ $misplaced = [];
+
+ foreach ($this->phpFilesIn($root) as $file) {
+ $relative = substr($file, strlen($root) + 1);
+
+ // Migrations and route files are loaded by path, not by class name,
+ // and migrations are deliberately anonymous classes.
+ if (str_starts_with($relative, 'Database/Migrations/') || str_starts_with($relative, 'Routes/')) {
+ continue;
+ }
+
+ $source = file_get_contents($file);
+
+ // Only a named type needs to be autoloadable; a file of helpers does not.
+ if (!preg_match('/^\s*(?:final\s+|abstract\s+|readonly\s+)*(?:class|interface|trait|enum)\s+(\w+)/m', $source, $type)) {
+ continue;
+ }
+
+ if (!preg_match('/^\s*namespace\s+([^;]+);/m', $source, $ns)) {
+ $misplaced[] = "$relative (no namespace declared)";
+ continue;
+ }
+
+ $declared = trim($ns[1]).'\\'.$type[1];
+ $expected = $prefix.str_replace('/', '\\', substr($relative, 0, -4));
+
+ if ($declared !== $expected) {
+ $misplaced[] = "$relative declares $declared, PSR-4 requires $expected";
+ }
+ }
+
+ $this->assertSame([], $misplaced, "PSR-4 violations:\n".implode("\n", $misplaced));
+ }
+
+ /**
+ * loadMigrationsFrom() keys migrations by filename. Two files sharing a name
+ * across the app means one of them silently never runs.
+ */
+ public function test_migration_filenames_are_unique_and_well_formed(): void
+ {
+ $dir = __DIR__.'/../src/Database/Migrations';
+
+ if (!is_dir($dir)) {
+ $this->markTestSkipped('Package ships no migrations.');
+ }
+
+ $names = [];
+
+ foreach (glob($dir.'/*.php') as $file) {
+ $name = basename($file, '.php');
+
+ $this->assertMatchesRegularExpression(
+ '/^\d{4}_\d{2}_\d{2}_\d{6}_\w+$/',
+ $name,
+ "Migration $name does not follow Laravel's timestamp_name convention."
+ );
+
+ $this->assertArrayNotHasKey($name, $names, "Duplicate migration name: $name");
+ $names[$name] = true;
+ }
+
+ $this->assertNotEmpty($names);
+ }
+
+ /**
+ * A migration file that does not return a Migration throws only when the
+ * migrator reaches it, i.e. during someone's deploy.
+ */
+ public function test_every_migration_returns_a_migration_instance(): void
+ {
+ $dir = __DIR__.'/../src/Database/Migrations';
+
+ if (!is_dir($dir)) {
+ $this->markTestSkipped('Package ships no migrations.');
+ }
+
+ foreach (glob($dir.'/*.php') as $file) {
+ $migration = require $file;
+
+ $this->assertInstanceOf(
+ \Illuminate\Database\Migrations\Migration::class,
+ $migration,
+ basename($file).' does not return a Migration instance.'
+ );
+ $this->assertTrue(
+ method_exists($migration, 'up'),
+ basename($file).' has no up() method.'
+ );
+ }
+ }
+
+ /**
+ * @return list
+ */
+ private function phpFilesIn(string $root): array
+ {
+ $files = [];
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
+ );
+
+ foreach ($iterator as $file) {
+ if ($file->isFile() && $file->getExtension() === 'php') {
+ $files[] = $file->getPathname();
+ }
+ }
+
+ sort($files);
+
+ return $files;
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..c16b777
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,14 @@
+