Files
branch-usage-checker/tests/Feature/Commands/CheckCommandTest.php
Ismo Vuorinen 2a37912685 chore: cleanup unused deps and dead code (#43)
* chore: remove unused code and dead files

Remove InspireCommand, GitHubApiBranch DTO, PackagistApiStatsPayload DTO,
and GitHubRestApi fetcher. Remove InspireCommand reference from config.

* chore(deps): remove unused composer dependencies

Remove guzzlehttp/guzzle, laravel-zero/phar-updater, and
nunomaduro/termwind. Clean up dead autoload entries and orphaned
allow-plugins configuration.

* feat: improve CheckCommand input handling and error reporting

Add vendor/package slash format support, input validation, HTTP error
handling, and concurrent stats fetching. Replace exit() calls with
return statements. Add 13 new test cases with Http::fake coverage.

* build: rebuild PHAR executable

* chore: add CLAUDE.md project instructions

* docs: add design for PHPCS + CaptainHook integration

* docs: add implementation plan for PHPCS + CaptainHook

* build(deps): add squizlabs/php_codesniffer

* build: add composer lint and format scripts

* build(deps): add captainhook and hook-installer

* build: configure CaptainHook pre-commit hook for PHPCS

* docs: add lint/format commands and hook info to CLAUDE.md

* build: add markdownlint-cli2 configuration

* build: add lint:md, lint:ec, lint:all, and format:md composer scripts

* build: add editorconfig and markdownlint to pre-commit hook

* docs: add new lint and format commands to CLAUDE.md

* chore: add missing gitignore patterns

* refactor: replace Http facade with injected HttpFactory

- Resolve HttpFactory via the container instead of using the Http facade
  directly, improving testability and explicitness
- Simplify early-return logic in package metadata error handling
- Reformat long strings and closures for PSR-12 line length compliance
- Extract repeated stats URL prefix into variable in tests

* build: add Claude Code shared settings and skills

Add shared hooks (auto-format PHP on edit, block vendor/lock edits),
shared permissions for common dev commands, and two user-invocable
skills (/build-phar, /release-check).

* refactor: extract test constants to reduce duplication in CheckCommandTest

Replace 6 occurrences of the command string and 4 occurrences of the
stats URL with TEST_COMMAND and TEST_STATS_URL constants. Fixes
SonarCloud S1192 code smells and brings duplicated lines density
below the 3% threshold.

* refactor: extract resolveInput and fetchPackageMetadata from handle()

Reduce handle() from 8 return statements to 3 by extracting input
validation into resolveInput() and HTTP fetching into
fetchPackageMetadata(). Fixes SonarCloud S1142 (too many returns).

* fix: improve CheckCommand error handling, input normalization, and timeouts

- Return exit code 1 from catch block instead of silently succeeding
- Normalize vendor/package inputs to lowercase before validation
- Add 10-second HTTP timeouts to metadata fetch and pool requests
- Return false from outputSuggestions when no suggestions found

* test: stub HTTP in live-request tests and assert exception output

- Add Http::fake() to slash-format and two-argument tests to prevent
  real Packagist requests during CI
- Assert exception message in try-block test and expect exit code 1
  to match the updated catch behavior

* docs: fix command signature, remove stale Fetchers reference in CLAUDE.md

- Change {package} to {package?} and note vendor/package combined form
- Remove non-existent app/Fetchers/ directory reference
- Update HTTP dependency note to reflect HttpFactory injection

* fix: guard array_combine against mismatched Packagist stats

Add a length check before array_combine() so malformed stats
(different label/value counts) produce a warning and skip the
branch instead of throwing a ValueError.

* refactor: change outputSuggestions return type to void

No caller uses the return value. Replace bool return type with
void and remove the now-unnecessary return statements.

* fix: narrow Throwable catch to Exception, add docblocks, remove unused default

- Catch \Exception instead of \Throwable so programming errors (TypeError,
  OutOfMemoryError) propagate instead of being silently displayed
- Add minimal PHPDoc summaries to all CheckCommand methods for coverage
- Remove unused default `= []` on outputSuggestions parameter
- Update test to expect TypeError propagation via ->throws()

* refactor: extract constants, fix dead store, deduplicate test setup

- Add TIMEOUT_SECONDS and PACKAGIST_URL constants in CheckCommand
- Replace inline timeout(10) and URL strings with constant references
- Fix dead store: $deletable[$k] = $values['Total'] → $deletable[] = $k
- Eliminate unused $keys intermediate variable in outputSuggestions()
- Standardize string formatting to use sprintf() consistently
- Add TEST_METADATA_URL constant and fakePackageResponses() helper in tests
- Refactor 6 tests to use shared helper, reducing Http::fake duplication

* docs: fix capitalization and remove stale GitHub reference in CLAUDE.md

* docs: add design for fixing SonarCloud duplication quality gate

* docs: add implementation plan for SonarCloud duplication fix

* refactor(tests): parameterize input-validation tests to fix duplication gate

* feat: add PHPMD linter and fix Codacy markdownlint config

Add PHPMD as a dev dependency with cleancode, codesize, unusedcode, and
naming rulesets. Integrate via composer lint:phpmd script, lint:all, and
CaptainHook pre-commit hook.

Split markdownlint config into .markdownlint.jsonc (rules) and
.markdownlint-cli2.jsonc (ignores only) so Codacy's plain markdownlint
can discover the shared rules file.

* fix: rename markdownlint config to .json and disable MD043

Rename .markdownlint.jsonc to .markdownlint.json so Codacy discovers
it at highest priority, and add required-headings: false to explicitly
disable MD043 which flags docs with varying heading structures.

* fix: guard against ConnectionException in pool responses and catch Throwable

Pool responses can be Throwable (e.g. ConnectionException) instead of
Response objects, causing crashes on ->failed(). Add instanceof guard,
widen catch to Throwable with TypeError re-throw, and extract loop into
collectBranchStats() to stay under cyclomatic complexity threshold.
2026-02-23 09:32:16 +02:00

166 lines
5.4 KiB
PHP

<?php
use Illuminate\Support\Facades\Http;
const TEST_VENDOR = 'test-vendor';
const TEST_PACKAGE = 'test-package';
const TEST_COMMAND = 'check ' . TEST_VENDOR . ' ' . TEST_PACKAGE;
const TEST_METADATA_URL = 'packagist.org/packages/' . TEST_VENDOR . '/' . TEST_PACKAGE . '.json';
const TEST_STATS_URL = 'packagist.org/packages/' . TEST_VENDOR . '/' . TEST_PACKAGE . '/stats';
beforeEach(function () {
Http::preventStrayRequests();
});
function validMetadata(): array
{
return [
'package' => [
'name' => TEST_VENDOR . '/' . TEST_PACKAGE,
'description' => 'Test',
'time' => '2024-01-01T00:00:00+00:00',
'type' => 'library',
'repository' => 'https://github.com/' . TEST_VENDOR . '/' . TEST_PACKAGE,
'language' => 'PHP',
'versions' => [
'dev-main' => ['version' => 'dev-main'],
'dev-feature' => ['version' => 'dev-feature'],
'1.0.0' => ['version' => '1.0.0'],
],
],
];
}
function statsResponse(array $downloads): array
{
return [
'labels' => ['2024-01', '2024-02', '2024-03'],
'values' => [$downloads],
];
}
function fakePackageResponses(array $statsPerBranch = []): void
{
$fakes = [TEST_METADATA_URL => Http::response(validMetadata())];
foreach ($statsPerBranch as $branch => $response) {
$fakes[TEST_STATS_URL . '/' . $branch . '.json*'] = $response;
}
Http::fake($fakes);
}
test('check command with slash format', function () {
fakePackageResponses([
'dev-feature' => Http::response(statsResponse([1, 2, 3])),
'dev-main' => Http::response(statsResponse([1, 2, 3])),
]);
$this->artisan('check ' . TEST_VENDOR . '/' . TEST_PACKAGE)
->assertExitCode(0);
});
test('check command with two arguments', function () {
fakePackageResponses([
'dev-feature' => Http::response(statsResponse([1, 2, 3])),
'dev-main' => Http::response(statsResponse([1, 2, 3])),
]);
$this->artisan('check ' . TEST_VENDOR . ' ' . TEST_PACKAGE)
->assertExitCode(0);
});
test('check command rejects invalid input', function (string $args, string $expected) {
$this->artisan($args)
->expectsOutputToContain($expected)
->assertExitCode(1);
})->with([
'missing package' => ['check ivuorinen', 'Missing package name'],
'conflicting arguments' => ['check ivuorinen/branch-usage-checker extra', 'Conflicting arguments'],
'invalid vendor' => ['check INVALID!/package-name', 'Invalid vendor name'],
'invalid package' => ['check valid-vendor INVALID!', 'Invalid package name'],
]);
test('check command with 404 shows package not found', function () {
Http::fake([
'packagist.org/packages/test-vendor/nonexistent-pkg.json' => Http::response([], 404),
]);
$this->artisan('check test-vendor nonexistent-pkg')
->expectsOutputToContain('Package not found')
->assertExitCode(1);
});
test('check command with 500 shows server error', function () {
Http::fake([
TEST_METADATA_URL => Http::response([], 500),
]);
$this->artisan(TEST_COMMAND)
->expectsOutputToContain('Failed to fetch package metadata (HTTP 500)')
->assertExitCode(1);
});
test('check command skips branch when stats fetch fails', function () {
fakePackageResponses([
'dev-feature' => Http::response([], 500),
'dev-main' => Http::response(statsResponse([10, 20, 30])),
]);
$this->artisan(TEST_COMMAND)
->expectsOutputToContain('Failed to fetch stats for dev-feature')
->assertExitCode(0);
});
test('check command skips branch on connection failure', function () {
fakePackageResponses([
'dev-feature' => Http::failedConnection(),
'dev-main' => Http::response(statsResponse([10, 20, 30])),
]);
$this->artisan(TEST_COMMAND)
->expectsOutputToContain('Failed to fetch stats for dev-feature')
->assertExitCode(0);
});
test('check command stops when all stats fail', function () {
fakePackageResponses([
'dev-feature' => Http::response([], 500),
'dev-main' => Http::response([], 500),
]);
$this->artisan(TEST_COMMAND)
->expectsOutputToContain('No statistics found... Stopping.')
->assertExitCode(0);
});
test('check command lets TypeError propagate from malformed payload', function () {
Http::fake([
TEST_METADATA_URL => Http::response([
'package' => ['versions' => 'not-an-array'],
]),
]);
$this->artisan(TEST_COMMAND);
})->throws(\TypeError::class);
test('check command shows no suggestions when all branches have downloads', function () {
fakePackageResponses([
'dev-main' => Http::response(statsResponse([10, 20, 30])),
'dev-feature' => Http::response(statsResponse([5, 10, 15])),
]);
$this->artisan(TEST_COMMAND)
->expectsOutputToContain('No suggestions available. Good job!')
->assertExitCode(0);
});
test('check command suggests branches with zero downloads', function () {
fakePackageResponses([
'dev-main' => Http::response(statsResponse([10, 20, 30])),
'dev-feature' => Http::response(statsResponse([0, 0, 0])),
]);
$this->artisan(TEST_COMMAND)
->expectsOutputToContain('Found 1 branches')
->assertExitCode(0);
});