diff --git a/lang/en/validation.php b/lang/en/validation.php index 7ff29cf..e0c8660 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -27,4 +27,5 @@ 'can_use_on_beam_pack' => 'This mutation is not applicable to non-beam packs.', 'can_use_on_beam' => 'This mutation is not applicable to beam packs.', 'beam_pack_exist_in_beam' => 'The :attribute doesn\'t exist in beam.', + 'tokens_exist_in_beam_pack' => 'The :attribute doesn\'t exist in beam pack.', ]; diff --git a/src/GraphQL/Mutations/RemoveTokensBeamPackMutation.php b/src/GraphQL/Mutations/RemoveTokensBeamPackMutation.php index f3d7206..80b72ee 100644 --- a/src/GraphQL/Mutations/RemoveTokensBeamPackMutation.php +++ b/src/GraphQL/Mutations/RemoveTokensBeamPackMutation.php @@ -4,8 +4,9 @@ use Closure; use Enjin\Platform\Beam\Rules\BeamExists; +use Enjin\Platform\Beam\Rules\BeamPackExistInBeam; use Enjin\Platform\Beam\Rules\CanUseOnBeamPack; -use Enjin\Platform\Beam\Rules\TokensExistInBeam; +use Enjin\Platform\Beam\Rules\TokensExistInBeamPack; use Enjin\Platform\Beam\Services\BeamService; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; @@ -77,16 +78,30 @@ protected function rules(array $args = []): array new BeamExists(), new CanUseOnBeamPack(), ], - 'tokenIds' => [ + 'packs' => [ + 'bail', + 'required', + 'array', + 'min:1', + 'max:1000', + ], + 'packs.*.id' => [ + 'filled', + 'integer', + 'distinct', + new BeamPackExistInBeam(), + ], + 'packs.*.tokenIds' => [ 'array', 'min:1', 'max:1000', + 'distinct', ], - 'tokenIds.*' => [ + 'packs.*.tokenIds.*' => [ 'bail', 'filled', 'distinct', - new TokensExistInBeam(), + new TokensExistInBeamPack(), ], ]; } diff --git a/src/Rules/BeamPackExistInBeam.php b/src/Rules/BeamPackExistInBeam.php index a655083..1d50bef 100644 --- a/src/Rules/BeamPackExistInBeam.php +++ b/src/Rules/BeamPackExistInBeam.php @@ -25,19 +25,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - if (!Beam::whereHas('packs', fn ($query) => $query->where('id', $value))->exists()) { - $fail($this->message())->translate(); + if (!Beam::where('code', $this->data['code'])->whereHas('packs', fn ($query) => $query->where('id', $value))->exists()) { + $fail('enjin-platform-beam::validation.beam_pack_exist_in_beam')->translate(); } } - - /** - * Get the validation error message. - * - * @return string - */ - public function message() - { - return 'enjin-platform-beam::validation.beam_pack_exist_in_beam'; - } } diff --git a/src/Rules/TokensExistInBeamPack.php b/src/Rules/TokensExistInBeamPack.php new file mode 100644 index 0000000..651bbe4 --- /dev/null +++ b/src/Rules/TokensExistInBeamPack.php @@ -0,0 +1,62 @@ +data, $key)) { + [$integers, $ranges] = collect($value)->partition(fn ($val) => $this->integerRange($val) === false); + if (count($integers)) { + $count = BeamClaim::whereIn('token_chain_id', $integers) + ->whereNull('claimed_at') + ->where('beam_pack_id', $id) + ->count(); + if ($count != count($integers)) { + $fail($this->message())->translate(); + + return; + } + } + foreach ($ranges as $range) { + [$from, $to] = $this->integerRange($range); + $count = BeamClaim::whereBetween('token_chain_id', [(int) $from, (int) $to]) + ->whereNull('claimed_at') + ->where('beam_pack_id', $id) + ->count(); + if ($count !== ($to - $from) + 1) { + $fail($this->message())->translate(); + } + } + } + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message() + { + return 'enjin-platform-beam::validation.tokens_exist_in_beam_pack'; + } +} diff --git a/src/Services/BeamService.php b/src/Services/BeamService.php index cb181b5..fea2251 100644 --- a/src/Services/BeamService.php +++ b/src/Services/BeamService.php @@ -368,30 +368,41 @@ public function deleteByCode(string $code): bool public function removeBeamPack(string $code, array $packs): bool { $packCollection = collect($packs)->keyBy('id'); - $beamPacks = BeamPack::whereHas('beam', fn ($query) => $query->where('code', $code)) - ->whereIn('id', $packCollection->pluck('id')) - ->get(['id']); - $deletedTokens = 0; $forDeletion = []; - foreach ($beamPacks as $pack) { + foreach ($packCollection as $pack) { if (empty($pack['tokenIds'])) { - $forDeletion[] = $pack->id; + $forDeletion[] = $pack['id']; continue; } - $ranges = $this->integerRange($pack['tokenIds']); - $deletedTokens += BeamClaim::where('beam_pack_id', $pack->id) - ->whereNull('claimed_at') - ->when($ranges === false, fn ($query) => $query->whereIn('token_chain_id', $pack['tokenIds'])) - ->when($ranges !== false, fn ($query) => $query->whereBetween('token_chain_id', [(int) $ranges[0], (int) $ranges[1]])) - ->delete(); + [$tokenIds, $tokenIdRanges] = collect($pack['tokenIds'])->partition(fn ($val) => $this->integerRange($val) === false); + if ($tokenIds) { + $deletedTokens += BeamClaim::where('beam_pack_id', $pack['id']) + ->whereNull('claimed_at') + ->whereIn('token_chain_id', $tokenIds) + ->delete(); + } + if ($tokenIdRanges) { + $deletedTokens += BeamClaim::where('beam_pack_id', $pack['id']) + ->whereNull('claimed_at') + ->where(function ($query) use ($tokenIdRanges) { + $tokenIdRanges->each(function ($tokenString) use ($query) { + $ranges = $this->integerRange($tokenString); + $query->orWhereBetween('token_chain_id', [(int) $ranges[0], (int) $ranges[1]]); + }); + }) + ->delete(); + } } - $beamPacks->loadCount('claims'); - $forDeletion = array_merge($forDeletion, $beamPacks->where('claims_count', 0)->all()); + $beamPacks = BeamPack::whereHas('beam', fn ($query) => $query->where('code', $code)) + ->whereIn('id', $packCollection->pluck('id')) + ->withCount('claims') + ->get(['id']); + $forDeletion = array_merge($forDeletion, $beamPacks->where('claims_count', 0)->pluck('id')->all()); if (count($forDeletion)) { BeamPack::whereIn('id', $forDeletion) ->whereDoesntHave('claims', fn ($query) => $query->whereNotNull('claimed_at')) @@ -399,7 +410,7 @@ public function removeBeamPack(string $code, array $packs): bool } if ($deletedTokens) { - TokensRemoved::safeBroadcast(event: ['code' => $code, 'tokenIds' => $packCollection->pluck('tokenIds')->all()]); + TokensRemoved::safeBroadcast(event: ['code' => $code, 'tokenIds' => $packCollection->pluck('tokenIds')->flatten()->all()]); } return true; diff --git a/tests/Feature/GraphQL/Mutations/ExpireSingleUseCodesTest.php b/tests/Feature/GraphQL/Mutations/ExpireSingleUseCodesTest.php index 743aab5..5d85258 100644 --- a/tests/Feature/GraphQL/Mutations/ExpireSingleUseCodesTest.php +++ b/tests/Feature/GraphQL/Mutations/ExpireSingleUseCodesTest.php @@ -86,7 +86,7 @@ public function test_it_cannot_claim_expire_single_use_codes_beam_pack(): void $response = $this->graphql('ClaimBeam', [ 'code' => Arr::get($singleUseCodes, 'edges.0.node.code'), - 'account' => app(Generator::class)->public_key() + 'account' => app(Generator::class)->public_key(), ], true); } diff --git a/tests/Feature/GraphQL/Mutations/RemovePackTokensTest.php b/tests/Feature/GraphQL/Mutations/RemovePackTokensTest.php new file mode 100644 index 0000000..c6f3fcf --- /dev/null +++ b/tests/Feature/GraphQL/Mutations/RemovePackTokensTest.php @@ -0,0 +1,208 @@ +seedBeamPack(2); + Event::fake(); + $claim = $this->claims->shift(); + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [['id' => $claim->beam_pack_id, 'tokenIds' => [$claim->token_chain_id]]], + ]); + $this->assertTrue($response); + Event::assertDispatched(TokensRemoved::class); + + $this->assertFalse( + BeamClaim::where('token_chain_id', $claim->token_chain_id) + ->where('beam_pack_id', $claim->beam_pack_id) + ->exists() + ); + } + + public function test_it_can_remove_token_range(): void + { + $this->seedBeamPack(2); + Event::fake(); + $claim = $this->claims->shift(); + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [['id' => $claim->beam_pack_id, 'tokenIds' => ["{$claim->token_chain_id}..{$claim->token_chain_id}"]]], + ]); + $this->assertTrue($response); + Event::assertDispatched(TokensRemoved::class); + + $this->assertFalse( + BeamClaim::whereBetween('token_chain_id', [$claim->token_chain_id, $claim->token_chain_id]) + ->where('beam_pack_id', $claim->beam_pack_id) + ->exists() + ); + } + + public function test_it_cannot_use_on_non_beam_pack(): void + { + $this->seedBeamPack(); + $this->beam->fill(['is_pack' => false])->save(); + $response = $this->graphql( + $this->method, + [ + 'code' => $this->beam->code, + 'packs' => [['id' => $this->claims->first()->id]], + ], + true + ); + $this->assertArraySubset( + ['code' => ['This mutation is not applicable to non-beam packs.']], + $response['error'] + ); + $this->beam->fill(['is_pack' => true])->save(); + } + + public function test_it_can_remove_beam_packs(): void + { + $this->seedBeamPack(2); + $packIds = $this->claims->pluck('beam_pack_id')->unique(); + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => $packIds->map(fn ($id) => ['id' => $id])->toArray(), + ]); + $this->assertTrue($response); + + $this->assertFalse(BeamPack::whereIn('id', $packIds)->exists()); + } + + public function test_it_will_fail_with_invalid_paramters(): void + { + $this->seedBeamPack(1); + $response = $this->graphql($this->method, [ + 'code' => null, + 'packs' => [[ + 'id' => $this->claims->first()->id, + 'tokenIds' => $this->claims->pluck('token_chain_id')->toArray(), + ]], + ], true); + $this->assertEquals( + 'Variable "$code" of non-null type "String!" must not be null.', + $response['error'] + ); + + $response = $this->graphql($this->method, [ + 'code' => '', + 'packs' => [[ + 'id' => $this->claims->first()->id, + 'tokenIds' => $this->claims->pluck('token_chain_id')->toArray(), + ]], + ], true); + $this->assertArraySubset( + ['code' => ['The code field must have a value.']], + $response['error'] + ); + + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => null, + ], true); + $this->assertEquals( + 'Variable "$packs" of non-null type "[RemoveBeamPack!]!" must not be null.', + $response['error'] + ); + + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [['tokenIds' => $this->claims->pluck('token_chain_id')->toArray()]], + ], true); + $this->assertStringContainsString( + 'Field "id" of required type "Int!" was not provided.', + $response['error'] + ); + + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [['id' => $this->claims->first()->beam_pack_id, 'tokenIds' => ['']]], + ], true); + $this->assertEquals( + 'Variable "$packs" got invalid value (empty string) at "packs[0].tokenIds[0]"; Cannot represent following value as integer range: (empty string)', + $response['error'] + ); + + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [['id' => $this->claims->first()->beam_pack_id, 'tokenIds' => [null]]], + ], true); + $this->assertEquals( + 'Variable "$packs" got invalid value null at "packs[0].tokenIds[0]"; Expected non-nullable type "IntegerRangeString!" not to be null.', + $response['error'] + ); + + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [['id' => 10001]], + ], true); + $this->assertArraySubset( + ['packs.0.id' => ['The packs.0.id doesn\'t exist in beam.']], + $response['error'] + ); + + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [['id' => $this->claims->first()->beam_pack_id, 'tokenIds' => ['1000001']]], + ], true); + $this->assertArraySubset( + ['packs.0.tokenIds.0' => ['The packs.0.tokenIds.0 doesn\'t exist in beam pack.']], + $response['error'] + ); + + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [['id' => $this->claims->first()->beam_pack_id, 'tokenIds' => ['1000001..1000002']]], + ], true); + $this->assertArraySubset( + ['packs.0.tokenIds.0' => ["The packs.0.tokenIds.0 doesn't exist in beam pack."]], + $response['error'] + ); + + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [ + ['id' => $this->claims->first()->beam_pack_id], + ['id' => $this->claims->first()->beam_pack_id], + ], + ], true); + $this->assertArraySubset( + [ + 'packs.0.id' => ['The packs.0.id field has a duplicate value.'], + 'packs.1.id' => ['The packs.1.id field has a duplicate value.'], + ], + $response['error'] + ); + + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'packs' => [['id' => $this->claims->first()->beam_pack_id, 'tokenIds' => ['1', '1']]], + ], true); + $this->assertArraySubset( + [ + 'packs.0.tokenIds.0' => ['The packs.0.tokenIds.0 field has a duplicate value.'], + 'packs.0.tokenIds.1' => ['The packs.0.tokenIds.1 field has a duplicate value.'], + ], + $response['error'] + ); + } +} diff --git a/tests/Feature/GraphQL/Resources/RemoveTokensBeamPack.graphql b/tests/Feature/GraphQL/Resources/RemoveTokensBeamPack.graphql new file mode 100644 index 0000000..7c09821 --- /dev/null +++ b/tests/Feature/GraphQL/Resources/RemoveTokensBeamPack.graphql @@ -0,0 +1,3 @@ +mutation RemoveTokensBeamPack($code: String!, $packs: [RemoveBeamPack!]!) { + RemoveTokensBeamPack(code: $code, packs: $packs) +}