diff --git a/app/Http/Controllers/PoliciesController.php b/app/Http/Controllers/PoliciesController.php index 1a3dfd65..48c28632 100644 --- a/app/Http/Controllers/PoliciesController.php +++ b/app/Http/Controllers/PoliciesController.php @@ -4,20 +4,46 @@ use App\Http\Resources\PoliciesCollection; use App\Policy; +use App\PolicyAcceptance; use Carbon\CarbonImmutable; +use Illuminate\Http\Request; class PoliciesController extends Controller { public function getCurrentPolicies(): PoliciesCollection { $now = CarbonImmutable::now(); // This works based on the assumption that the latest policy has the highest id given that id is AUTO_INCREMENT + $latestPolicyIds = $this->getLatestPolicies(); + + $currentPolicies = Policy::whereIn('id', $latestPolicyIds)->get(); + + return new PoliciesCollection($currentPolicies); + } + + public function getMissingPolicies(Request $request): PoliciesCollection { + $now = CarbonImmutable::now(); + + $activePolicyIds = $this->getLatestPolicies(); + + $userAcceptedPolicyIds = PolicyAcceptance::where('user_id', $request->user()->id) + ->whereIn('policy_id', $activePolicyIds) + ->pluck('policy_id'); + + $missingPolicies = Policy::whereIn('id', $activePolicyIds) + ->whereNotIn('id', $userAcceptedPolicyIds) + ->get(); + + return new PoliciesCollection($missingPolicies); + } + + private function getLatestPolicies() { + $now = CarbonImmutable::now(); + $latestPolicyIds = Policy::where('active_from', '<', $now) ->selectRaw('MAX(id) as id') ->groupBy('policy_type') ->pluck('id'); - $currentPolicies = Policy::whereIn('id', $latestPolicyIds)->get(); - - return new PoliciesCollection($currentPolicies); + return $latestPolicyIds; } } diff --git a/routes/api.php b/routes/api.php index d6dc19fa..c7b306f7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -29,6 +29,7 @@ $router->group(['middleware' => ['auth:api']], function () use ($router): void { $router->get('auth/login', ['uses' => 'Auth\LoginController@getLogin']); $router->delete('auth/login', ['uses' => 'Auth\LoginController@deleteLogin']); + $router->get('v1/policies/missing', ['uses' => 'PoliciesController@getMissingPolicies']); // policy acceptances $router->put('v1/policy_acceptances', ['uses' => 'PolicyAcceptanceController@store']); diff --git a/tests/Http/Controllers/PoliciesControllerTest.php b/tests/Http/Controllers/PoliciesControllerTest.php deleted file mode 100644 index 7c9e030b..00000000 --- a/tests/Http/Controllers/PoliciesControllerTest.php +++ /dev/null @@ -1,60 +0,0 @@ -create([ - 'policy_type' => 'terms-of-use', - 'active_from' => $currentTime->addDay(), - ]); - // Old policy - Policy::factory()->create([ - 'policy_type' => 'hosting-policy', - 'active_from' => $currentTime->subMonth(), - ]); - // Active policies - $latestActiveToUPolicy = Policy::factory()->create([ - 'policy_type' => 'terms-of-use', - 'active_from' => $currentTime->subMonth(), - ]); - $latestActiveHostingPolicy = Policy::factory()->create([ - 'policy_type' => 'hosting-policy', - 'active_from' => $currentTime->subWeek(), - ]); - - $response = $this->getJson('/v1/policies/current'); - - $response->assertOk(); - $response->assertJsonStructure([ - 'items' => [ - '*' => [ - 'metadata' => [ - 'policy_id', - 'active_from', - 'content_vue_file', - 'type', - ], - ], - ], - ]); - - $response->assertJsonFragment([ - 'policy_id' => $latestActiveToUPolicy->id, - 'active_from' => $latestActiveToUPolicy->active_from->format('Y-m-d'), - ]); - $response->assertJsonFragment([ - 'policy_id' => $latestActiveHostingPolicy->id, - 'active_from' => $latestActiveHostingPolicy->active_from->format('Y-m-d'), - ]); - } -} diff --git a/tests/Routes/PoliciesControllerTest.php b/tests/Routes/PoliciesControllerTest.php new file mode 100644 index 00000000..9eb6f348 --- /dev/null +++ b/tests/Routes/PoliciesControllerTest.php @@ -0,0 +1,180 @@ + $type, + 'active_from' => $activeFrom, + 'content_vue_file' => $content, + ]); + } + + public function testMissingPoliciesRequiresAuthentication(): void { + $this->json('GET', $this->missingPoliciesRoute) + ->assertStatus(401); + } + + public function testGetCurrentPolicies(): void { + $now = CarbonImmutable::now(); + + // Future policy + $this->createPolicy('terms-of-use', $now->addDay(), 'terms-of-use/version-future.vue'); + + // Older active policy of the same type should be excluded + $this->createPolicy('hosting-policy', $now->subMonths(2), 'hosting-policy/version-1.vue'); + + // Active policies + $latestActiveToUPolicy = $this->createPolicy('terms-of-use', $now->subMonth(), 'terms-of-use/version-2.vue'); + $latestActiveHostingPolicy = $this->createPolicy('hosting-policy', $now->subWeek(), 'hosting-policy/version-2.vue'); + + $response = $this->json('GET', $this->currentPoliciesRoute); + + $response->assertOk(); + $response->assertJsonStructure([ + 'items' => [ + '*' => [ + 'metadata' => [ + 'policy_id', + 'active_from', + 'content_vue_file', + 'type', + ], + ], + ], + ]); + + $response->assertJsonFragment([ + 'policy_id' => $latestActiveToUPolicy->id, + 'active_from' => $latestActiveToUPolicy->active_from->format('Y-m-d'), + ]); + $response->assertJsonFragment([ + 'policy_id' => $latestActiveHostingPolicy->id, + 'active_from' => $latestActiveHostingPolicy->active_from->format('Y-m-d'), + ]); + } + + public function testMissingPoliciesReturnsOnlyLatestCurrentPolicyPerType(): void { + $user = User::factory()->create(); + $now = CarbonImmutable::now(); + + // Future policy (should be ignored because it is not active yet) + $this->createPolicy('terms-of-use', $now->addDays(10), 'terms-of-use/version-future.vue'); + + $olderTerms = $this->createPolicy('terms-of-use', $now->subDays(10), 'terms-of-use/version-1.vue'); + $latestTerms = $this->createPolicy('terms-of-use', $now->subDays(1), 'terms-of-use/version-2.vue'); + $this->createPolicy('terms-of-use', $now->addDays(1), 'terms-of-use/version-3.vue'); + + $olderHosting = $this->createPolicy('hosting-policy', $now->subDays(20), 'hosting-policy/version-1.vue'); + $latestHosting = $this->createPolicy('hosting-policy', $now->subDays(2), 'hosting-policy/version-2.vue'); + + $response = $this->actingAs($user, 'api') + ->json('GET', $this->missingPoliciesRoute) + ->assertStatus(200) + ->assertJsonStructure([ + 'items' => [ + ['metadata' => ['policy_id', 'type', 'active_from', 'content_vue_file']], + ], + ]); + + $items = collect($response->json('items')); + + $this->assertCount(2, $items); + + $this->assertTrue($items->contains(function (array $item) use ($latestTerms): bool { + return $item['metadata']['policy_id'] === $latestTerms->id + && $item['metadata']['type'] === 'terms-of-use' + && $item['metadata']['active_from'] === $latestTerms->active_from->format('Y-m-d') + && $item['metadata']['content_vue_file'] === 'terms-of-use/version-2.vue'; + })); + + $this->assertTrue($items->contains(function (array $item) use ($latestHosting): bool { + return $item['metadata']['policy_id'] === $latestHosting->id + && $item['metadata']['type'] === 'hosting-policy' + && $item['metadata']['active_from'] === $latestHosting->active_from->format('Y-m-d') + && $item['metadata']['content_vue_file'] === 'hosting-policy/version-2.vue'; + })); + + $this->assertFalse($items->contains(function (array $item) use ($olderTerms): bool { + return $item['metadata']['policy_id'] === $olderTerms->id; + })); + + $this->assertFalse($items->contains(function (array $item) use ($olderHosting): bool { + return $item['metadata']['policy_id'] === $olderHosting->id; + })); + + $this->assertCount(1, $items->where('metadata.type', 'terms-of-use')); + $this->assertCount(1, $items->where('metadata.type', 'hosting-policy')); + } + + public function testMissingPoliciesExcludesAlreadyAcceptedPoliciesForCurrentUser(): void { + $user = User::factory()->create(); + $anotherUser = User::factory()->create(); + $now = CarbonImmutable::now(); + + $terms = $this->createPolicy('terms-of-use', $now->subDays(1), 'terms-of-use/version-2.vue'); + $hosting = $this->createPolicy('hosting-policy', $now->subDays(1), 'hosting-policy/version-1.vue'); + + PolicyAcceptance::create([ + 'user_id' => $user->id, + 'policy_id' => $terms->id, + 'accepted_at' => $now, + ]); + + // Acceptance by another user must not affect current user response + PolicyAcceptance::create([ + 'user_id' => $anotherUser->id, + 'policy_id' => $hosting->id, + 'accepted_at' => $now, + ]); + + $response = $this->actingAs($user, 'api') + ->json('GET', $this->missingPoliciesRoute) + ->assertStatus(200); + + $items = collect($response->json('items')); + + $this->assertCount(1, $items); + $this->assertSame($hosting->id, $items->first()['metadata']['policy_id']); + $this->assertSame('hosting-policy', $items->first()['metadata']['type']); + } + + public function testMissingPoliciesReturnsEmptyListWhenAllCurrentPoliciesAccepted(): void { + $user = User::factory()->create(); + $now = CarbonImmutable::now(); + + $terms = $this->createPolicy('terms-of-use', $now->subDays(1), 'terms-of-use/version-2.vue'); + $hosting = $this->createPolicy('hosting-policy', $now->subDays(1), 'hosting-policy/version-1.vue'); + + PolicyAcceptance::create([ + 'user_id' => $user->id, + 'policy_id' => $terms->id, + 'accepted_at' => $now, + ]); + + PolicyAcceptance::create([ + 'user_id' => $user->id, + 'policy_id' => $hosting->id, + 'accepted_at' => $now, + ]); + + $this->actingAs($user, 'api') + ->json('GET', $this->missingPoliciesRoute) + ->assertStatus(200) + ->assertJson(['items' => []]); + } +}