From 3c31e8bd4746fab1b5c60579935fab21501b87b6 Mon Sep 17 00:00:00 2001 From: Dat Date: Fri, 17 Jul 2026 16:54:34 +0200 Subject: [PATCH 01/10] Added controller method that returns only current policies not accepted by user Bug: T432337 --- app/Http/Controllers/PoliciesController.php | 22 +++++++++++++++++++++ routes/api.php | 1 + 2 files changed, 23 insertions(+) diff --git a/app/Http/Controllers/PoliciesController.php b/app/Http/Controllers/PoliciesController.php index 1a3dfd65..2d67ee6c 100644 --- a/app/Http/Controllers/PoliciesController.php +++ b/app/Http/Controllers/PoliciesController.php @@ -4,7 +4,9 @@ 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 { @@ -20,4 +22,24 @@ public function getCurrentPolicies(): PoliciesCollection { return new PoliciesCollection($currentPolicies); } + + public function getMissingPolicies(Request $request): 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 = Policy::where('active_from', '<', $now) + ->selectRaw('MAX(id) as id') + ->groupBy('policy_type') + ->pluck('id'); + + $acceptedPolicyIds = PolicyAcceptance::where('user_id', $request->user()->id) + ->whereIn('policy_id', $latestPolicyIds) + ->pluck('policy_id'); + + $missingPolicies = Policy::whereIn('id', $latestPolicyIds) + ->whereNotIn('id', $acceptedPolicyIds) + ->get(); + + return new PoliciesCollection($missingPolicies); + } } diff --git a/routes/api.php b/routes/api.php index 125bc994..9f8c4acb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -28,6 +28,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']); From 191fac253f2dd4d7fdedb84081f938be0d3cfc79 Mon Sep 17 00:00:00 2001 From: Dat Date: Mon, 20 Jul 2026 15:38:20 +0200 Subject: [PATCH 02/10] Add test class --- tests/Routes/PoliciesControllerTest.php | 136 ++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/Routes/PoliciesControllerTest.php diff --git a/tests/Routes/PoliciesControllerTest.php b/tests/Routes/PoliciesControllerTest.php new file mode 100644 index 00000000..19fbfd9e --- /dev/null +++ b/tests/Routes/PoliciesControllerTest.php @@ -0,0 +1,136 @@ + $type, + 'active_from' => $activeFrom, + 'content_vue_file' => $content, + ]); + } + + public function testMissingPoliciesRequiresAuthentication(): void { + $this->json('GET', $this->missingPoliciesRoute) + ->assertStatus(401); + } + + public function testMissingPoliciesReturnsOnlyLatestCurrentPolicyPerType(): void { + $user = User::factory()->create(); + $now = CarbonImmutable::now(); + + $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' => []]); + } +} From ea4924f0ed6da72fffa9fd0307cf14ce0be47ed2 Mon Sep 17 00:00:00 2001 From: Dat Date: Mon, 20 Jul 2026 16:22:24 +0200 Subject: [PATCH 03/10] change query condition and variables' names --- app/Http/Controllers/PoliciesController.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/PoliciesController.php b/app/Http/Controllers/PoliciesController.php index 2d67ee6c..17c0558e 100644 --- a/app/Http/Controllers/PoliciesController.php +++ b/app/Http/Controllers/PoliciesController.php @@ -26,17 +26,17 @@ public function getCurrentPolicies(): PoliciesCollection { public function getMissingPolicies(Request $request): 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 = Policy::where('active_from', '<', $now) + // This works based on the assumption that the active policy has the highest id given that id is AUTO_INCREMENT + $activePolicyIds = Policy::where('active_from', '<=', $now) ->selectRaw('MAX(id) as id') ->groupBy('policy_type') ->pluck('id'); $acceptedPolicyIds = PolicyAcceptance::where('user_id', $request->user()->id) - ->whereIn('policy_id', $latestPolicyIds) + ->whereIn('policy_id', $activePolicyIds) ->pluck('policy_id'); - $missingPolicies = Policy::whereIn('id', $latestPolicyIds) + $missingPolicies = Policy::whereIn('id', $activePolicyIds) ->whereNotIn('id', $acceptedPolicyIds) ->get(); From fe2a622be73218ad1a5b7ca4c0899c44203e1fa3 Mon Sep 17 00:00:00 2001 From: Dat Date: Mon, 20 Jul 2026 16:23:18 +0200 Subject: [PATCH 04/10] verify that future policies are excluded --- tests/Routes/PoliciesControllerTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Routes/PoliciesControllerTest.php b/tests/Routes/PoliciesControllerTest.php index 19fbfd9e..631fb37b 100644 --- a/tests/Routes/PoliciesControllerTest.php +++ b/tests/Routes/PoliciesControllerTest.php @@ -31,6 +31,9 @@ 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'); From 6ef72d91f7f68e8b92b3809eac5489f3098a8b21 Mon Sep 17 00:00:00 2001 From: Dat Date: Mon, 20 Jul 2026 16:45:41 +0200 Subject: [PATCH 05/10] remove unrelated comment --- app/Http/Controllers/PoliciesController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/Http/Controllers/PoliciesController.php b/app/Http/Controllers/PoliciesController.php index 17c0558e..331dc176 100644 --- a/app/Http/Controllers/PoliciesController.php +++ b/app/Http/Controllers/PoliciesController.php @@ -26,7 +26,6 @@ public function getCurrentPolicies(): PoliciesCollection { public function getMissingPolicies(Request $request): PoliciesCollection { $now = CarbonImmutable::now(); - // This works based on the assumption that the active policy has the highest id given that id is AUTO_INCREMENT $activePolicyIds = Policy::where('active_from', '<=', $now) ->selectRaw('MAX(id) as id') ->groupBy('policy_type') From 55af3261cc821afde84c69058863e4db874ec961 Mon Sep 17 00:00:00 2001 From: Perside Rosalie Date: Mon, 20 Jul 2026 14:27:20 +0200 Subject: [PATCH 06/10] Create endpoint to get specific policy by type and active_from date (#1210) Bug: T432339 --- app/Http/Controllers/PolicyController.php | 37 +++++++++++++++++ routes/api.php | 1 + .../Http/Controllers/PolicyControllerTest.php | 41 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 app/Http/Controllers/PolicyController.php create mode 100644 tests/Http/Controllers/PolicyControllerTest.php diff --git a/app/Http/Controllers/PolicyController.php b/app/Http/Controllers/PolicyController.php new file mode 100644 index 00000000..8527fdaf --- /dev/null +++ b/app/Http/Controllers/PolicyController.php @@ -0,0 +1,37 @@ + $policyType, + 'active_from' => $activeFrom, + ], + [ + 'policy_type' => ['required', 'string', Rule::in(['terms-of-use', 'hosting-policy'])], + 'active_from' => ['required', 'date', 'date_format:Y-m-d'], + ] + + ); + $validator->validate(); + $validated = $validator->safe(); + + $validatedActiveFrom = CarbonImmutable::parse($validated['active_from']); + + $policy = Policy::where('policy_type', $validated['policy_type'])->where('active_from', '=', $validatedActiveFrom)->first(); + + if (!$policy) { + abort(404, 'Policy not found.'); + } + + return new PolicyResource($policy); + } +} diff --git a/routes/api.php b/routes/api.php index 9f8c4acb..c7b306f7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,6 +22,7 @@ $router->post('contact/sendMessage', ['uses' => 'ContactController@sendMessage']); $router->post('complaint/sendMessage', ['uses' => 'ComplaintController@sendMessage']); $router->get('v1/policies/current', ['uses' => 'PoliciesController@getCurrentPolicies']); + $router->get('v1/policies/{policy_type}/by_active_from/{active_from}', ['uses' => 'PolicyController@getPolicyByTypeAndActiveFrom']); $router->post('auth/login', ['uses' => 'Auth\LoginController@postLogin'])->name('login'); // Authed diff --git a/tests/Http/Controllers/PolicyControllerTest.php b/tests/Http/Controllers/PolicyControllerTest.php new file mode 100644 index 00000000..b3527248 --- /dev/null +++ b/tests/Http/Controllers/PolicyControllerTest.php @@ -0,0 +1,41 @@ +create([ + 'policy_type' => 'hosting-policy', + 'active_from' => '2026-07-01', + ]); + + Policy::factory()->create([ + 'policy_type' => 'hosting-policy', + 'active_from' => '2026-07-02', + ]); + + $request = $this->getJson('v1/policies/hosting-policy/by_active_from/2026-07-01'); + + $request->assertOk(); + $request->assertJsonFragment([ + 'active_from' => '2026-07-01', + 'type' => 'hosting-policy', + ]); + } + + public function testGetPolicyByTypeAndActiveFromReturns422WithInvalidParams(): void { + $request = $this->getJson('v1/policies/fake-policy/by_active_from/not-a-date'); + $request->assertUnprocessable(); + } + + public function testMissingPolicyByTypeAndActiveFromReturns404(): void { + $request = $this->getJson('v1/policies/hosting-policy/by_active_from/2026-07-01'); + $request->assertNotFound(); + } +} From 831fb67237813795cf72b58cca6981315031d9d6 Mon Sep 17 00:00:00 2001 From: Dat Date: Fri, 17 Jul 2026 16:54:34 +0200 Subject: [PATCH 07/10] Added controller method that returns only current policies not accepted by user Bug: T432337 --- app/Http/Controllers/PoliciesController.php | 36 ++++++++++----------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/app/Http/Controllers/PoliciesController.php b/app/Http/Controllers/PoliciesController.php index 331dc176..ee1c9d45 100644 --- a/app/Http/Controllers/PoliciesController.php +++ b/app/Http/Controllers/PoliciesController.php @@ -4,21 +4,15 @@ use App\Http\Resources\PoliciesCollection; use App\Policy; -use App\PolicyAcceptance; use Carbon\CarbonImmutable; +use Illuminate\Database\Query\Builder; 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 = Policy::where('active_from', '<', $now) - ->selectRaw('MAX(id) as id') - ->groupBy('policy_type') - ->pluck('id'); - - $currentPolicies = Policy::whereIn('id', $latestPolicyIds)->get(); + $currentPolicies = Policy::whereIn('id', $this->activePolicyIdsQuery($now))->get(); return new PoliciesCollection($currentPolicies); } @@ -26,19 +20,23 @@ public function getCurrentPolicies(): PoliciesCollection { public function getMissingPolicies(Request $request): PoliciesCollection { $now = CarbonImmutable::now(); - $activePolicyIds = Policy::where('active_from', '<=', $now) - ->selectRaw('MAX(id) as id') - ->groupBy('policy_type') - ->pluck('id'); - - $acceptedPolicyIds = PolicyAcceptance::where('user_id', $request->user()->id) - ->whereIn('policy_id', $activePolicyIds) - ->pluck('policy_id'); - - $missingPolicies = Policy::whereIn('id', $activePolicyIds) - ->whereNotIn('id', $acceptedPolicyIds) + $missingPolicies = Policy::whereIn('id', $this->activePolicyIdsQuery($now)) + ->whereNotExists(function (Builder $query) use ($request): void { + $query->selectRaw('1') + ->from('policy_acceptances') + ->whereColumn('policy_acceptances.policy_id', 'policies.id') + ->where('policy_acceptances.user_id', $request->user()->id); + }) ->get(); return new PoliciesCollection($missingPolicies); } + + private function activePolicyIdsQuery(CarbonImmutable $now): Builder { + return Policy::query() + ->selectRaw('MAX(id) as id') + ->where('active_from', '<=', $now) + ->groupBy('policy_type') + ->toBase(); + } } From 7ad928632f5e09ef3628be670129b04a420779f3 Mon Sep 17 00:00:00 2001 From: Dat Date: Mon, 20 Jul 2026 15:38:20 +0200 Subject: [PATCH 08/10] Add test class --- tests/Routes/PoliciesControllerTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Routes/PoliciesControllerTest.php b/tests/Routes/PoliciesControllerTest.php index 631fb37b..ca0513f6 100644 --- a/tests/Routes/PoliciesControllerTest.php +++ b/tests/Routes/PoliciesControllerTest.php @@ -33,7 +33,6 @@ public function testMissingPoliciesReturnsOnlyLatestCurrentPolicyPerType(): void // 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'); From fe1b918e0a297fab5839b372fd57994c41fe5c55 Mon Sep 17 00:00:00 2001 From: Dat Date: Tue, 21 Jul 2026 17:31:49 +0200 Subject: [PATCH 09/10] merge PoliciesControllerTest class into one file --- .../Controllers/PoliciesControllerTest.php | 60 ------------------- tests/Routes/PoliciesControllerTest.php | 41 +++++++++++++ 2 files changed, 41 insertions(+), 60 deletions(-) delete mode 100644 tests/Http/Controllers/PoliciesControllerTest.php 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 index ca0513f6..daa0f355 100644 --- a/tests/Routes/PoliciesControllerTest.php +++ b/tests/Routes/PoliciesControllerTest.php @@ -12,6 +12,8 @@ class PoliciesControllerTest extends TestCase { use DatabaseTransactions; + private string $currentPoliciesRoute = 'v1/policies/current'; + private string $missingPoliciesRoute = 'v1/policies/missing'; private function createPolicy(string $type, CarbonImmutable $activeFrom, string $content): Policy { @@ -27,6 +29,45 @@ public function testMissingPoliciesRequiresAuthentication(): void { ->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(); From 7915ad2837ce4f4658d76728b195437d82267d9f Mon Sep 17 00:00:00 2001 From: Dat Date: Wed, 22 Jul 2026 10:01:32 +0200 Subject: [PATCH 10/10] Undo rebase/merge side effect --- tests/Http/Controllers/PolicyControllerTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Http/Controllers/PolicyControllerTest.php b/tests/Http/Controllers/PolicyControllerTest.php index b3527248..56829d65 100644 --- a/tests/Http/Controllers/PolicyControllerTest.php +++ b/tests/Http/Controllers/PolicyControllerTest.php @@ -23,6 +23,14 @@ public function testGetPolicyByTypeAndActiveFrom(): void { $request = $this->getJson('v1/policies/hosting-policy/by_active_from/2026-07-01'); $request->assertOk(); + $request->assertJsonStructure([ + 'metadata' => [ + 'policy_id', + 'active_from', + 'content_vue_file', + 'type', + ], + ]); $request->assertJsonFragment([ 'active_from' => '2026-07-01', 'type' => 'hosting-policy',