Skip to content

Commit 8ce087c

Browse files
authored
Add endpoint for creating a policy acceptance for a specific user (#1197)
**Summary** - Add authenticated endpoint to record policy acceptances for the current user, with strict validation and all-or-nothing behavior for invalid policy IDs. **Behavior** - Uses authenticated user from session context (no `user_id` in payload) - Ignores already-accepted policies (idempotent writes via `firstOrCreate`) - If any provided policy ID does not exist, returns `400` with `missing_policy_ids` - No acceptances are created when invalid policy IDs are present - Returns `422` for invalid payload (missing `policy_ids`, non-array, non-integer items) Bug: T431674
1 parent 7ba0eb9 commit 8ce087c

3 files changed

Lines changed: 203 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Policy;
6+
use App\PolicyAcceptance;
7+
use Illuminate\Http\JsonResponse;
8+
use Illuminate\Http\Request;
9+
10+
class PolicyAcceptanceController extends Controller {
11+
public function store(Request $request): JsonResponse {
12+
$request->validate([
13+
'policy_ids' => ['required', 'array'],
14+
'policy_ids.*' => ['integer'],
15+
]);
16+
17+
$policyIds = $request->input('policy_ids');
18+
19+
// Check all policy IDs exist before writing anything
20+
$existingIds = Policy::whereIn('id', $policyIds)->pluck('id')->all();
21+
$missingIds = array_values(array_diff($policyIds, $existingIds));
22+
23+
if (!empty($missingIds)) {
24+
return response()->json([
25+
'success' => false,
26+
'message' => 'Some policy IDs do not exist.',
27+
'data' => ['missing_policy_ids' => $missingIds],
28+
], 400);
29+
}
30+
31+
$userId = $request->user()->id;
32+
33+
foreach ($policyIds as $policyId) {
34+
// Ignore if the user has already accepted this policy
35+
PolicyAcceptance::firstOrCreate(
36+
['user_id' => $userId, 'policy_id' => $policyId],
37+
['accepted_at' => now()],
38+
);
39+
}
40+
41+
return response()->json(['success' => true]);
42+
}
43+
}

routes/api.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
$router->get('auth/login', ['uses' => 'Auth\LoginController@getLogin']);
2929
$router->delete('auth/login', ['uses' => 'Auth\LoginController@deleteLogin']);
3030

31+
// policy acceptances
32+
$router->put('v1/policy_acceptances', ['uses' => 'PolicyAcceptanceController@store']);
33+
3134
// user
3235
$router->group(['prefix' => 'user'], function () use ($router): void {
3336
$router->post('sendVerifyEmail', ['uses' => 'UserVerificationTokenController@createAndSendForUser']);
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
<?php
2+
3+
namespace Tests\Routes;
4+
5+
use App\Policy;
6+
use App\PolicyAcceptance;
7+
use App\User;
8+
use Carbon\Carbon;
9+
use Carbon\CarbonImmutable;
10+
use Illuminate\Foundation\Testing\DatabaseTransactions;
11+
use Tests\TestCase;
12+
13+
class PolicyAcceptanceControllerTest extends TestCase {
14+
protected $route = 'v1/policy_acceptances';
15+
16+
use DatabaseTransactions;
17+
18+
private function makePolicy(string $type = 'terms-of-use'): Policy {
19+
$policy = new Policy();
20+
$policy->policy_type = $type;
21+
$policy->active_from = CarbonImmutable::now();
22+
$policy->content_vue_file = $type . '/version-1.vue';
23+
$policy->save();
24+
25+
return $policy;
26+
}
27+
28+
public function testUnauthenticatedRequestResponds401(): void {
29+
$this->json('PUT', $this->route)
30+
->assertStatus(401);
31+
}
32+
33+
public function testAcceptSinglePolicy(): void {
34+
$user = User::factory()->create();
35+
$policy = $this->makePolicy();
36+
37+
$this->actingAs($user, 'api')
38+
->json('PUT', $this->route, ['policy_ids' => [$policy->id]])
39+
->assertStatus(200)
40+
->assertJson(['success' => true]);
41+
42+
$this->assertDatabaseHas('policy_acceptances', [
43+
'user_id' => $user->id,
44+
'policy_id' => $policy->id,
45+
]);
46+
}
47+
48+
public function testAcceptMultiplePolicies(): void {
49+
$user = User::factory()->create();
50+
$termsOfUse = $this->makePolicy('terms-of-use');
51+
$hostingPolicy = $this->makePolicy('hosting-policy');
52+
53+
$this->actingAs($user, 'api')
54+
->json('PUT', $this->route, ['policy_ids' => [$termsOfUse->id, $hostingPolicy->id]])
55+
->assertStatus(200)
56+
->assertJson(['success' => true]);
57+
58+
$this->assertDatabaseHas('policy_acceptances', ['user_id' => $user->id, 'policy_id' => $termsOfUse->id]);
59+
$this->assertDatabaseHas('policy_acceptances', ['user_id' => $user->id, 'policy_id' => $hostingPolicy->id]);
60+
}
61+
62+
public function testAlreadyAcceptedPolicyIsIgnored(): void {
63+
$user = User::factory()->create();
64+
$policy = $this->makePolicy();
65+
66+
PolicyAcceptance::create([
67+
'user_id' => $user->id,
68+
'policy_id' => $policy->id,
69+
'accepted_at' => now(),
70+
]);
71+
72+
$this->actingAs($user, 'api')
73+
->json('PUT', $this->route, ['policy_ids' => [$policy->id]])
74+
->assertStatus(200)
75+
->assertJson(['success' => true]);
76+
77+
$this->assertSame(1, PolicyAcceptance::where([
78+
'user_id' => $user->id,
79+
'policy_id' => $policy->id,
80+
])->count());
81+
}
82+
83+
public function testAlreadyAcceptedPolicyKeepsOriginalAcceptedAt(): void {
84+
$user = User::factory()->create();
85+
$policy = $this->makePolicy();
86+
87+
$originalAcceptedAt = CarbonImmutable::create(2026, 7, 1, 10, 0, 0);
88+
Carbon::setTestNow(CarbonImmutable::create(2026, 7, 2, 10, 0, 0));
89+
90+
PolicyAcceptance::create([
91+
'user_id' => $user->id,
92+
'policy_id' => $policy->id,
93+
'accepted_at' => $originalAcceptedAt,
94+
]);
95+
96+
$this->actingAs($user, 'api')
97+
->json('PUT', $this->route, ['policy_ids' => [$policy->id]])
98+
->assertStatus(200)
99+
->assertJson(['success' => true]);
100+
101+
$this->assertEquals($originalAcceptedAt->toDateTimeString(), PolicyAcceptance::where([
102+
'user_id' => $user->id,
103+
'policy_id' => $policy->id,
104+
])->first()->accepted_at->toDateTimeString());
105+
}
106+
107+
public function testNonExistentPolicyIdReturns400(): void {
108+
$user = User::factory()->create();
109+
$policy = $this->makePolicy();
110+
$nonExistentId = 999999;
111+
112+
$this->actingAs($user, 'api')
113+
->json('PUT', $this->route, ['policy_ids' => [$policy->id, $nonExistentId]])
114+
->assertStatus(400)
115+
->assertJsonFragment(['success' => false])
116+
->assertJsonFragment(['missing_policy_ids' => [$nonExistentId]]);
117+
118+
// Nothing should have been written
119+
$this->assertDatabaseMissing('policy_acceptances', [
120+
'user_id' => $user->id,
121+
'policy_id' => $policy->id,
122+
]);
123+
}
124+
125+
public function testMissingPolicyIdsFieldReturns422(): void {
126+
$user = User::factory()->create();
127+
128+
$this->actingAs($user, 'api')
129+
->json('PUT', $this->route, [])
130+
->assertStatus(422)
131+
->assertJsonStructure(['errors' => ['policy_ids']]);
132+
}
133+
134+
public function testPolicyIdsNotAnArrayReturns422(): void {
135+
$user = User::factory()->create();
136+
137+
$this->actingAs($user, 'api')
138+
->json('PUT', $this->route, ['policy_ids' => 'not-an-array'])
139+
->assertStatus(422)
140+
->assertJsonStructure(['errors' => ['policy_ids']]);
141+
}
142+
143+
public function testPolicyIdsContainingNonIntegerReturns422(): void {
144+
$user = User::factory()->create();
145+
$policy = $this->makePolicy();
146+
147+
$this->actingAs($user, 'api')
148+
->json('PUT', $this->route, ['policy_ids' => [$policy->id, 'abc']])
149+
->assertStatus(422)
150+
->assertJsonStructure(['errors' => ['policy_ids.1']]);
151+
152+
$this->assertDatabaseMissing('policy_acceptances', [
153+
'user_id' => $user->id,
154+
'policy_id' => $policy->id,
155+
]);
156+
}
157+
}

0 commit comments

Comments
 (0)