From e031787e06f7418e465b46b0f64e613fa36b10c4 Mon Sep 17 00:00:00 2001 From: Gabriel do Carmo Vieira <48625433+gvieira18@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:21:46 -0300 Subject: [PATCH 1/3] build(deps): bump minor dependencies --- composer.json | 8 ++-- composer.lock | 106 +++++++++++++++++++++++++------------------------- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/composer.json b/composer.json index 3169d821..4f44194a 100644 --- a/composer.json +++ b/composer.json @@ -10,10 +10,10 @@ "require": { "php": "^8.4", "calebporzio/sushi": "^2.5.4", - "dedoc/scramble": "^0.13.27", + "dedoc/scramble": "^0.13.28", "filament/filament": "^5.6.7", "filament/spatie-laravel-media-library-plugin": "^5.6.7", - "guzzlehttp/guzzle": "^7.11.1", + "guzzlehttp/guzzle": "^7.11.2", "he4rt/activity": ">=1", "he4rt/bot-discord": ">=1.0", "he4rt/community": ">=1", @@ -35,7 +35,7 @@ "he4rt/profile": ">=1", "internachi/modular": "^3.0.2", "laracord/framework": "dev-next#e7b64d6", - "laravel/framework": "^13.15.0", + "laravel/framework": "^13.16.1", "laravel/nightwatch": "^1.28.0", "laravel/sanctum": "^4.3.2", "laravel/telescope": "^5.20.0", @@ -63,7 +63,7 @@ "laravel/boost": "^2.4.10", "laravel/pail": "^1.2.7", "laravel/pao": "^1.1.1", - "laravel/pint": "^1.29.1", + "laravel/pint": "^1.29.3", "laravel/sail": "^1.62.0", "mockery/mockery": "^1.6.12", "mrpunyapal/rector-pest": "^0.2.15", diff --git a/composer.lock b/composer.lock index 17c51f7b..8be88ed5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5d3960e6d2e6b299908bcb9e6cd2a16c", + "content-hash": "8f06ec1170adb5089fa6481c29438802", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -1226,16 +1226,16 @@ }, { "name": "dedoc/scramble", - "version": "v0.13.27", + "version": "v0.13.28", "source": { "type": "git", "url": "https://github.com/dedoc/scramble.git", - "reference": "5b067b1168b4092ee10b320469a09823610f48b1" + "reference": "e50927c732a341bb743671066892eec6bd2e958b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dedoc/scramble/zipball/5b067b1168b4092ee10b320469a09823610f48b1", - "reference": "5b067b1168b4092ee10b320469a09823610f48b1", + "url": "https://api.github.com/repos/dedoc/scramble/zipball/e50927c732a341bb743671066892eec6bd2e958b", + "reference": "e50927c732a341bb743671066892eec6bd2e958b", "shasum": "" }, "require": { @@ -1294,7 +1294,7 @@ ], "support": { "issues": "https://github.com/dedoc/scramble/issues", - "source": "https://github.com/dedoc/scramble/tree/v0.13.27" + "source": "https://github.com/dedoc/scramble/tree/v0.13.28" }, "funding": [ { @@ -1302,7 +1302,7 @@ "type": "github" } ], - "time": "2026-06-12T12:41:44+00:00" + "time": "2026-06-14T18:21:12+00:00" }, { "name": "dflydev/dot-access-data", @@ -2656,16 +2656,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.11.1", + "version": "7.11.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c" + "reference": "bf5f35ad4b774b9d7c5766c02035e865e7e3fdab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c", - "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/bf5f35ad4b774b9d7c5766c02035e865e7e3fdab", + "reference": "bf5f35ad4b774b9d7c5766c02035e865e7e3fdab", "shasum": "" }, "require": { @@ -2764,7 +2764,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.11.1" + "source": "https://github.com/guzzle/guzzle/tree/7.11.2" }, "funding": [ { @@ -2780,7 +2780,7 @@ "type": "tidelift" } ], - "time": "2026-06-07T22:54:06+00:00" + "time": "2026-06-12T21:49:57+00:00" }, { "name": "guzzlehttp/promises", @@ -2868,16 +2868,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.11.0", + "version": "2.11.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" + "reference": "640e2897bbee822dbc8af761d49e1a29b1f2a6b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/640e2897bbee822dbc8af761d49e1a29b1f2a6b1", + "reference": "640e2897bbee822dbc8af761d49e1a29b1f2a6b1", "shasum": "" }, "require": { @@ -2967,7 +2967,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.11.0" + "source": "https://github.com/guzzle/psr7/tree/2.11.1" }, "funding": [ { @@ -2983,7 +2983,7 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:30:48+00:00" + "time": "2026-06-12T21:50:12+00:00" }, { "name": "guzzlehttp/uri-template", @@ -3941,16 +3941,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.9.0", + "version": "6.10.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886" + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/bd1bda2ebfc8bff418565941771ea8f03c557886", - "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", "shasum": "" }, "require": { @@ -3960,7 +3960,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "^23.2", + "json-schema/json-schema-test-suite": "dev-main", "marc-mabe/php-enum-phpstan": "^2.0", "phpspec/prophecy": "^1.19", "phpstan/phpstan": "^1.12", @@ -4010,9 +4010,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.9.0" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.10.0" }, - "time": "2026-06-05T14:05:24+00:00" + "time": "2026-06-16T20:50:26+00:00" }, { "name": "kirschbaum-development/eloquent-power-joins", @@ -4162,16 +4162,16 @@ }, { "name": "laravel/framework", - "version": "v13.15.0", + "version": "v13.16.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "7e23b2aa4e1133a43835c93a810b4bedc40e425b" + "reference": "6135650d69bd9442e470bb1b343422081b076f1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/7e23b2aa4e1133a43835c93a810b4bedc40e425b", - "reference": "7e23b2aa4e1133a43835c93a810b4bedc40e425b", + "url": "https://api.github.com/repos/laravel/framework/zipball/6135650d69bd9442e470bb1b343422081b076f1e", + "reference": "6135650d69bd9442e470bb1b343422081b076f1e", "shasum": "" }, "require": { @@ -4382,7 +4382,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-06-09T13:45:51+00:00" + "time": "2026-06-16T16:07:50+00:00" }, { "name": "laravel/nightwatch", @@ -13589,16 +13589,16 @@ }, { "name": "team-reflex/discord-php", - "version": "v10.48.8", + "version": "v10.48.11", "source": { "type": "git", "url": "https://github.com/discord-php/DiscordPHP.git", - "reference": "67cdc333a98d4558ef2b09df5bb48b93965f2076" + "reference": "0659ab5374683da11b6473cadbe983dd84c3842b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/discord-php/DiscordPHP/zipball/67cdc333a98d4558ef2b09df5bb48b93965f2076", - "reference": "67cdc333a98d4558ef2b09df5bb48b93965f2076", + "url": "https://api.github.com/repos/discord-php/DiscordPHP/zipball/0659ab5374683da11b6473cadbe983dd84c3842b", + "reference": "0659ab5374683da11b6473cadbe983dd84c3842b", "shasum": "" }, "require": { @@ -13668,7 +13668,7 @@ "chat": "https://discord.gg/dphp", "docs": "https://discord-php.github.io/DiscordPHP/", "issues": "https://github.com/discord-php/DiscordPHP/issues", - "source": "https://github.com/discord-php/DiscordPHP/tree/v10.48.8", + "source": "https://github.com/discord-php/DiscordPHP/tree/v10.48.11", "wiki": "https://github.com/discord-php/DiscordPHP/wiki" }, "funding": [ @@ -13689,7 +13689,7 @@ "type": "patreon" } ], - "time": "2026-05-18T04:58:48+00:00" + "time": "2026-06-16T14:32:16+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -15255,16 +15255,16 @@ }, { "name": "laravel/pint", - "version": "v1.29.1", + "version": "v1.29.3", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", - "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "url": "https://api.github.com/repos/laravel/pint/zipball/da1d1111a6aa2e082d2a388b194afe1ba0a05d14", + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14", "shasum": "" }, "require": { @@ -15275,14 +15275,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.95.1", - "illuminate/view": "^12.56.0", - "larastan/larastan": "^3.9.6", + "friendsofphp/php-cs-fixer": "^3.95.8", + "illuminate/view": "^12.62.0", + "larastan/larastan": "^3.10.0", "laravel-zero/framework": "^12.1.0", + "laravel/agent-detector": "^2.0.2", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", - "pestphp/pest": "^3.8.6", - "shipfastlabs/agent-detector": "^1.1.3" + "pestphp/pest": "^3.8.6" }, "bin": [ "builds/pint" @@ -15319,7 +15319,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-04-20T15:26:14+00:00" + "time": "2026-06-16T15:34:04+00:00" }, { "name": "laravel/roster", @@ -18481,16 +18481,16 @@ }, { "name": "webmozart/assert", - "version": "2.4.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -18541,9 +18541,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-05-20T13:07:01+00:00" + "time": "2026-06-15T15:31:57+00:00" } ], "aliases": [], From 95f3ba48b0409a5e3e5be5557f53bca17d02c651 Mon Sep 17 00:00:00 2001 From: Gabriel do Carmo Vieira <48625433+gvieira18@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:22:00 -0300 Subject: [PATCH 2/3] feat(integration-discord): add purge-invites command Artisan command to delete unused Discord guild invites. Supports --dry-run preview and --include-expiring flag. --- .../src/IntegrationDiscordServiceProvider.php | 2 + .../Sync/Actions/PurgeUnusedInvitesAction.php | 93 ++++++++++ .../Console/PurgeUnusedInvitesCommand.php | 83 +++++++++ .../Requests/Invites/DeleteInvite.php | 22 +++ .../Requests/Invites/ListGuildInvites.php | 22 +++ .../Sync/PurgeUnusedInvitesActionTest.php | 172 ++++++++++++++++++ .../Transport/Requests/DeleteInviteTest.php | 18 ++ .../Requests/ListGuildInvitesTest.php | 18 ++ 8 files changed, 430 insertions(+) create mode 100644 app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php create mode 100644 app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php create mode 100644 app-modules/integration-discord/src/Transport/Requests/Invites/DeleteInvite.php create mode 100644 app-modules/integration-discord/src/Transport/Requests/Invites/ListGuildInvites.php create mode 100644 app-modules/integration-discord/tests/Unit/Sync/PurgeUnusedInvitesActionTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/Requests/DeleteInviteTest.php create mode 100644 app-modules/integration-discord/tests/Unit/Transport/Requests/ListGuildInvitesTest.php diff --git a/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php b/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php index 7b06ab52..a849ce6d 100644 --- a/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php +++ b/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php @@ -9,6 +9,7 @@ use He4rt\IntegrationDiscord\ETL\Console\ImportDiscordProfilesCommand; use He4rt\IntegrationDiscord\ETL\Console\MergeDuplicateDiscordProfilesCommand; use He4rt\IntegrationDiscord\Models\DiscordEventLog; +use He4rt\IntegrationDiscord\Sync\Console\PurgeUnusedInvitesCommand; use He4rt\IntegrationDiscord\Sync\Console\SyncDiscordGuildCommand; use He4rt\IntegrationDiscord\Sync\Observers\DiscordEventLogObserver; use He4rt\IntegrationDiscord\Transport\DiscordConnector; @@ -41,6 +42,7 @@ public function boot(): void MergeDuplicateDiscordProfilesCommand::class, SyncDiscordGuildCommand::class, BackfillVoiceLogsCommand::class, + PurgeUnusedInvitesCommand::class, ]); } } diff --git a/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php b/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php new file mode 100644 index 00000000..87d38d4d --- /dev/null +++ b/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php @@ -0,0 +1,93 @@ +} + */ + public function execute(string $guildId, bool $dryRun = false, bool $includeExpiring = false): array + { + $response = $this->connector->send(new ListGuildInvites($guildId)); + + /** @var list> $allInvites */ + $allInvites = $response->json(); + + $unused = array_filter( + $allInvites, + static fn (array $invite): bool => ($invite['uses'] ?? -1) === 0 + && ($includeExpiring || ($invite['max_age'] ?? -1) === 0), + ); + + $matches = array_values(array_map( + static fn (array $invite): array => [ + 'code' => $invite['code'], + 'channel' => $invite['channel']['name'] ?? 'unknown', + 'inviter' => $invite['inviter']['username'] ?? 'unknown', + 'created_at' => isset($invite['created_at']) + ? Date::parse($invite['created_at'])->timezone(config('app.display_timezone'))->format('d/m/Y H:i') + : '', + ], + $unused, + )); + + if ($dryRun) { + return [ + 'total' => count($allInvites), + 'matched' => count($unused), + 'deleted' => 0, + 'failed' => 0, + 'invites' => $matches, + ]; + } + + $deleted = 0; + $failed = 0; + + foreach ($unused as $index => $invite) { + if ($index > 0) { + Sleep::usleep(random_int(200_000, 500_000)); + } + + try { + $response = $this->connector->send(new DeleteInvite($invite['code'])); + + if ($response->failed()) { + throw new RuntimeException(sprintf('HTTP %d: %s', $response->status(), $response->body())); + } + + $deleted++; + } catch (Throwable $e) { + $failed++; + Log::warning('Failed to delete Discord invite', [ + 'code' => $invite['code'], + 'error' => $e->getMessage(), + ]); + } + } + + return [ + 'total' => count($allInvites), + 'matched' => count($unused), + 'deleted' => $deleted, + 'failed' => $failed, + 'invites' => $matches, + ]; + } +} diff --git a/app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php b/app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php new file mode 100644 index 00000000..76c8278c --- /dev/null +++ b/app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php @@ -0,0 +1,83 @@ +argument('guild_id') ?? config('he4rt.discord.guild_id'); + + if ($guildId === null) { + $this->error('No guild ID provided and no default configured.'); + + return self::FAILURE; + } + + $dryRun = (bool) $this->option('dry-run'); + $includeExpiring = (bool) $this->option('include-expiring'); + + $scope = $includeExpiring ? 'unused' : 'unused infinite'; + + $this->info(sprintf( + '%s %s invites for guild %s...', + $dryRun ? 'Scanning' : 'Purging', + $scope, + $guildId, + )); + + $result = $action->execute((string) $guildId, $dryRun, $includeExpiring); + + if ($result['matched'] === 0) { + $this->info(sprintf('No %s invites found. Nothing to do.', $scope)); + + return self::SUCCESS; + } + + $this->table( + ['Code', 'Inviter', 'Channel', 'Created At'], + array_map( + static fn (array $invite): array => [ + $invite['code'], + $invite['inviter'], + $invite['channel'], + $invite['created_at'], + ], + $result['invites'], + ), + ); + + $this->newLine(); + $this->info(sprintf( + 'Found %d %s invite(s) out of %d total.', + $result['matched'], + $scope, + $result['total'], + )); + + if ($dryRun) { + $this->warn('DRY RUN -- no invites were deleted.'); + + return self::SUCCESS; + } + + $this->info(sprintf('Deleted %d invite(s).', $result['deleted'])); + + if ($result['failed'] > 0) { + $this->warn(sprintf('%d invite(s) failed to delete. Check logs for details.', $result['failed'])); + + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/app-modules/integration-discord/src/Transport/Requests/Invites/DeleteInvite.php b/app-modules/integration-discord/src/Transport/Requests/Invites/DeleteInvite.php new file mode 100644 index 00000000..bc6f9a59 --- /dev/null +++ b/app-modules/integration-discord/src/Transport/Requests/Invites/DeleteInvite.php @@ -0,0 +1,22 @@ +inviteCode); + } +} diff --git a/app-modules/integration-discord/src/Transport/Requests/Invites/ListGuildInvites.php b/app-modules/integration-discord/src/Transport/Requests/Invites/ListGuildInvites.php new file mode 100644 index 00000000..1fce4c39 --- /dev/null +++ b/app-modules/integration-discord/src/Transport/Requests/Invites/ListGuildInvites.php @@ -0,0 +1,22 @@ +guildId); + } +} diff --git a/app-modules/integration-discord/tests/Unit/Sync/PurgeUnusedInvitesActionTest.php b/app-modules/integration-discord/tests/Unit/Sync/PurgeUnusedInvitesActionTest.php new file mode 100644 index 00000000..b8d264ea --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Sync/PurgeUnusedInvitesActionTest.php @@ -0,0 +1,172 @@ + $code, + 'max_age' => $maxAge, + 'uses' => $uses, + 'max_uses' => 0, + 'channel' => ['name' => $channel], + 'inviter' => ['username' => $inviter], + 'created_at' => '2025-01-15T10:00:00+00:00', + ]; +} + +it('identifies unused infinite invites in dry-run mode', function (): void { + $invites = [ + makeInvite('aaa', maxAge: 0, uses: 0), + makeInvite('bbb', maxAge: 3_600, uses: 0), + makeInvite('ccc', maxAge: 0, uses: 5), + makeInvite('ddd', maxAge: 0, uses: 0, channel: 'dev-chat', inviter: 'bob'), + ]; + + $mockClient = new MockClient([ + ListGuildInvites::class => MockResponse::make($invites), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $action = new PurgeUnusedInvitesAction($connector); + $result = $action->execute('guild-123', dryRun: true); + + expect($result['total'])->toBe(4) + ->and($result['matched'])->toBe(2) + ->and($result['deleted'])->toBe(0) + ->and($result['failed'])->toBe(0) + ->and($result['invites'])->toHaveCount(2) + ->and($result['invites'][0]['code'])->toBe('aaa') + ->and($result['invites'][1]['code'])->toBe('ddd') + ->and($result['invites'][1]['channel'])->toBe('dev-chat') + ->and($result['invites'][1]['inviter'])->toBe('bob'); + + $mockClient->assertNotSent(DeleteInvite::class); +}); + +it('deletes matching invites in live mode', function (): void { + $invites = [ + makeInvite('aaa', maxAge: 0, uses: 0), + makeInvite('bbb', maxAge: 3_600, uses: 0), + makeInvite('ccc', maxAge: 0, uses: 0), + ]; + + $mockClient = new MockClient([ + ListGuildInvites::class => MockResponse::make($invites), + DeleteInvite::class => MockResponse::make([], 204), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $action = new PurgeUnusedInvitesAction($connector); + $result = $action->execute('guild-123', dryRun: false); + + expect($result['total'])->toBe(3) + ->and($result['matched'])->toBe(2) + ->and($result['deleted'])->toBe(2) + ->and($result['failed'])->toBe(0); + + $mockClient->assertSentCount(3); +}); + +it('returns zero matches when no invites qualify', function (): void { + $invites = [ + makeInvite('aaa', maxAge: 3_600, uses: 0), + makeInvite('bbb', maxAge: 0, uses: 3), + makeInvite('ccc', maxAge: 86_400, uses: 10), + ]; + + $mockClient = new MockClient([ + ListGuildInvites::class => MockResponse::make($invites), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $action = new PurgeUnusedInvitesAction($connector); + $result = $action->execute('guild-123', dryRun: false); + + expect($result['total'])->toBe(3) + ->and($result['matched'])->toBe(0) + ->and($result['deleted'])->toBe(0) + ->and($result['invites'])->toBeEmpty(); +}); + +it('counts failures individually without aborting', function (): void { + $invites = [ + makeInvite('aaa', maxAge: 0, uses: 0), + makeInvite('bbb', maxAge: 0, uses: 0), + makeInvite('ccc', maxAge: 0, uses: 0), + ]; + + $mockClient = new MockClient([ + MockResponse::make($invites), + MockResponse::make([], 204), + MockResponse::make(['message' => 'Unknown Invite'], 404), + MockResponse::make([], 204), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $action = new PurgeUnusedInvitesAction($connector); + $result = $action->execute('guild-123', dryRun: false); + + expect($result['matched'])->toBe(3) + ->and($result['deleted'])->toBe(2) + ->and($result['failed'])->toBe(1); +}); + +it('includes expiring invites when flag is set', function (): void { + $invites = [ + makeInvite('aaa', maxAge: 0, uses: 0), + makeInvite('bbb', maxAge: 3_600, uses: 0), + makeInvite('ccc', maxAge: 86_400, uses: 0), + makeInvite('ddd', maxAge: 0, uses: 3), + makeInvite('eee', maxAge: 3_600, uses: 2), + ]; + + $mockClient = new MockClient([ + ListGuildInvites::class => MockResponse::make($invites), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $action = new PurgeUnusedInvitesAction($connector); + $result = $action->execute('guild-123', dryRun: true, includeExpiring: true); + + expect($result['total'])->toBe(5) + ->and($result['matched'])->toBe(3) + ->and($result['invites'][0]['code'])->toBe('aaa') + ->and($result['invites'][1]['code'])->toBe('bbb') + ->and($result['invites'][2]['code'])->toBe('ccc'); + + $mockClient->assertNotSent(DeleteInvite::class); +}); + +it('handles an empty invite list', function (): void { + $mockClient = new MockClient([ + ListGuildInvites::class => MockResponse::make([]), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $action = new PurgeUnusedInvitesAction($connector); + $result = $action->execute('guild-123'); + + expect($result['total'])->toBe(0) + ->and($result['matched'])->toBe(0) + ->and($result['invites'])->toBeEmpty(); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/Requests/DeleteInviteTest.php b/app-modules/integration-discord/tests/Unit/Transport/Requests/DeleteInviteTest.php new file mode 100644 index 00000000..29808ffb --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/Requests/DeleteInviteTest.php @@ -0,0 +1,18 @@ +getMethod())->toBe(Method::DELETE); +}); + +it('resolves the correct endpoint', function (): void { + $request = new DeleteInvite('hNQoYb9'); + + expect($request->resolveEndpoint())->toBe('/invites/hNQoYb9'); +}); diff --git a/app-modules/integration-discord/tests/Unit/Transport/Requests/ListGuildInvitesTest.php b/app-modules/integration-discord/tests/Unit/Transport/Requests/ListGuildInvitesTest.php new file mode 100644 index 00000000..7d5fae1f --- /dev/null +++ b/app-modules/integration-discord/tests/Unit/Transport/Requests/ListGuildInvitesTest.php @@ -0,0 +1,18 @@ +getMethod())->toBe(Method::GET); +}); + +it('resolves the correct endpoint', function (): void { + $request = new ListGuildInvites('guild-123'); + + expect($request->resolveEndpoint())->toBe('/guilds/guild-123/invites'); +}); From 71a66215963d6bbfc2c8d27f5f9569dec68a1fca Mon Sep 17 00:00:00 2001 From: Gabriel do Carmo Vieira <48625433+gvieira18@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:34:01 -0300 Subject: [PATCH 3/3] fix(integration-discord): validate list invites response --- .../src/Sync/Actions/PurgeUnusedInvitesAction.php | 4 ++++ .../tests/Unit/Sync/PurgeUnusedInvitesActionTest.php | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php b/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php index 87d38d4d..ee2ebb5a 100644 --- a/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php +++ b/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php @@ -26,6 +26,10 @@ public function execute(string $guildId, bool $dryRun = false, bool $includeExpi { $response = $this->connector->send(new ListGuildInvites($guildId)); + if ($response->failed()) { + throw new RuntimeException(sprintf('Failed to list guild invites: HTTP %d', $response->status())); + } + /** @var list> $allInvites */ $allInvites = $response->json(); diff --git a/app-modules/integration-discord/tests/Unit/Sync/PurgeUnusedInvitesActionTest.php b/app-modules/integration-discord/tests/Unit/Sync/PurgeUnusedInvitesActionTest.php index b8d264ea..f6c4d5c2 100644 --- a/app-modules/integration-discord/tests/Unit/Sync/PurgeUnusedInvitesActionTest.php +++ b/app-modules/integration-discord/tests/Unit/Sync/PurgeUnusedInvitesActionTest.php @@ -155,6 +155,18 @@ function makeInvite(string $code, int $maxAge, int $uses, string $channel = 'gen $mockClient->assertNotSent(DeleteInvite::class); }); +it('throws when list invites response fails', function (): void { + $mockClient = new MockClient([ + ListGuildInvites::class => MockResponse::make(['message' => 'Missing Permissions'], 403), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $action = new PurgeUnusedInvitesAction($connector); + $action->execute('guild-123'); +})->throws(RuntimeException::class, 'Failed to list guild invites: HTTP 403'); + it('handles an empty invite list', function (): void { $mockClient = new MockClient([ ListGuildInvites::class => MockResponse::make([]),