Skip to content

Commit 7f60f37

Browse files
tarrowoutdooracorn
andauthored
Create schemas and models for implementing generic policies (#1177)
Models, migrations, and simple tests for Policy and PolicyAcceptance. These will be used to track which policies exist over the lifetime of the platform and when each user accepted them. Use built in `created_at` and `updated_at` timestamps via the `$table->timestamps()` method, for consistency with our other models. Does not include Factories or Seeders. Possibility for a follow-up. Bug: T428175 --------- Co-authored-by: Ollie <oliver.hyde@wikimedia.de> Co-authored-by: Ollie <43674967+outdooracorn@users.noreply.github.com>
1 parent b19dec5 commit 7f60f37

6 files changed

Lines changed: 283 additions & 0 deletions

app/Policy.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace App;
4+
5+
use Carbon\CarbonImmutable;
6+
use Eloquent;
7+
use Illuminate\Database\Eloquent\Builder;
8+
use Illuminate\Database\Eloquent\Model;
9+
10+
/**
11+
* @property int $id
12+
* @property string $policy_type
13+
* @property CarbonImmutable|null $active_from
14+
* @property string $content_vue_file
15+
* @property CarbonImmutable|null $created_at
16+
* @property CarbonImmutable|null $updated_at
17+
*
18+
* @method static Builder<static>|Policy newModelQuery()
19+
* @method static Builder<static>|Policy newQuery()
20+
* @method static Builder<static>|Policy query()
21+
* @method static Builder<static>|Policy whereActiveFrom($value)
22+
* @method static Builder<static>|Policy whereContentVueFile($value)
23+
* @method static Builder<static>|Policy whereCreatedAt($value)
24+
* @method static Builder<static>|Policy whereId($value)
25+
* @method static Builder<static>|Policy wherePolicyType($value)
26+
* @method static Builder<static>|Policy whereUpdatedAt($value)
27+
*
28+
* @mixin Eloquent
29+
*/
30+
class Policy extends Model {
31+
// define which attributes are mass assignable
32+
protected $fillable = [
33+
'policy_type',
34+
'active_from',
35+
'content_vue_file',
36+
];
37+
38+
// define the default value of model attributes when a new instance is created
39+
protected $attributes = [
40+
'active_from' => null,
41+
];
42+
43+
protected function casts(): array {
44+
return [
45+
// cast `active_from` to a `CarbonImmutable` instance rather than a string
46+
'active_from' => 'immutable_date',
47+
48+
// cast to `CarbonImmutable` until we default to using `CarbonImmutable` globally in T430656
49+
'created_at' => 'immutable_datetime',
50+
'updated_at' => 'immutable_datetime',
51+
];
52+
}
53+
}

app/PolicyAcceptance.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace App;
4+
5+
use Carbon\CarbonImmutable;
6+
use Eloquent;
7+
use Illuminate\Database\Eloquent\Builder;
8+
use Illuminate\Database\Eloquent\Model;
9+
10+
/**
11+
* This model uses a separate `accepted_at` property rather than renaming the default `created_at` property because:
12+
* - it remains consistent with other models that use the default timestamps
13+
* - `accepted_at` will be before `created_at` when backfilling the terms-of-use acceptances
14+
*
15+
* @property int $id
16+
* @property int $user_id
17+
* @property int $policy_id
18+
* @property CarbonImmutable $accepted_at
19+
* @property CarbonImmutable|null $created_at
20+
* @property CarbonImmutable|null $updated_at
21+
*
22+
* @method static Builder<static>|PolicyAcceptance newModelQuery()
23+
* @method static Builder<static>|PolicyAcceptance newQuery()
24+
* @method static Builder<static>|PolicyAcceptance query()
25+
* @method static Builder<static>|PolicyAcceptance whereAcceptedAt($value)
26+
* @method static Builder<static>|PolicyAcceptance whereCreatedAt($value)
27+
* @method static Builder<static>|PolicyAcceptance whereId($value)
28+
* @method static Builder<static>|PolicyAcceptance wherePolicyId($value)
29+
* @method static Builder<static>|PolicyAcceptance whereUpdatedAt($value)
30+
* @method static Builder<static>|PolicyAcceptance whereUserId($value)
31+
*
32+
* @mixin Eloquent
33+
*/
34+
class PolicyAcceptance extends Model {
35+
protected $fillable = [
36+
'user_id',
37+
'policy_id',
38+
'accepted_at',
39+
];
40+
41+
protected function casts(): array {
42+
return [
43+
// cast `accepted_at` to a `CarbonImmutable` instance rather than a string
44+
'accepted_at' => 'immutable_datetime',
45+
46+
// cast to `CarbonImmutable` until we default to using `CarbonImmutable` globally in T430656
47+
'created_at' => 'immutable_datetime',
48+
'updated_at' => 'immutable_datetime',
49+
];
50+
}
51+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class() extends Migration {
8+
/**
9+
* Run the migrations.
10+
*/
11+
public function up(): void {
12+
Schema::create('policies', function (Blueprint $table) {
13+
$table->id();
14+
$table->enum('policy_type', ['terms-of-use', 'hosting-policy']);
15+
$table->date('active_from')->nullable()->default(null);
16+
$table->string('content_vue_file', 255);
17+
18+
// Use Eloquent built in to create nullable `created_at` and `updated_at` timestamp fields
19+
$table->timestamps();
20+
21+
// Prevent two policies of the same type with `active_from` set to the same value
22+
// (including `null` for upcoming policies)
23+
$table->unique(['policy_type', 'active_from']);
24+
});
25+
}
26+
27+
/**
28+
* Reverse the migrations.
29+
*/
30+
public function down(): void {
31+
Schema::dropIfExists('policies');
32+
}
33+
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class() extends Migration {
8+
/**
9+
* Run the migrations.
10+
*/
11+
public function up(): void {
12+
Schema::create('policy_acceptances', function (Blueprint $table) {
13+
$table->id();
14+
15+
// Can't use the `foreignId()` method because the `users.id` column isn't an unsigned big integer
16+
$table->unsignedInteger('user_id');
17+
$table->foreign('user_id')->references('id')->on('users')->restrictOnUpdate()->restrictOnDelete();
18+
19+
$table->foreignId('policy_id')->constrained()->restrictOnUpdate()->restrictOnDelete();
20+
21+
// Use a separate `accepted_at` column rather than renaming the default `created_at` column because:
22+
// * it reduces confusion by remaining consistent with other tables that use these default columns
23+
// * `accepted_at` will be before `created_at` when backfilling the terms-of-use acceptances
24+
// Use a DATETIME field as MariaDB has odd behavior around TIMESTAMPs and default values
25+
// `accepted_at` can be NOT NULL if we use a DATETIME which provides us with more data safety
26+
$table->dateTime('accepted_at');
27+
28+
// Use Eloquent built in to create nullable `created_at` and `updated_at` timestamp fields
29+
$table->timestamps();
30+
31+
// Prevent a user from accepting the same policy twice
32+
$table->unique(['user_id', 'policy_id']);
33+
});
34+
}
35+
36+
/**
37+
* Reverse the migrations.
38+
*/
39+
public function down(): void {
40+
Schema::dropIfExists('policy_acceptances');
41+
}
42+
};

tests/PolicyAcceptanceTest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
namespace Tests;
4+
5+
use App\Policy;
6+
use App\PolicyAcceptance;
7+
use App\User;
8+
use Carbon\CarbonImmutable;
9+
use Illuminate\Foundation\Testing\RefreshDatabase;
10+
use RuntimeException;
11+
12+
class PolicyAcceptanceTest extends TestCase {
13+
use RefreshDatabase;
14+
15+
protected int $userId;
16+
17+
protected int $policyId;
18+
19+
protected function setUp(): void {
20+
parent::setUp();
21+
$user = User::factory()->create();
22+
$this->userId = $user->id;
23+
$policy = Policy::create([
24+
'policy_type' => 'terms-of-use',
25+
'active_from' => CarbonImmutable::yesterday(),
26+
'content_vue_file' => 'terms-of-use/example.vue',
27+
]);
28+
$this->policyId = $policy->id;
29+
}
30+
31+
public function testCreatesAndSavesSuccessfully(): void {
32+
$policyAcceptance = new PolicyAcceptance([
33+
'user_id' => $this->userId,
34+
'policy_id' => $this->policyId,
35+
'accepted_at' => CarbonImmutable::now(),
36+
]);
37+
$policyAcceptance->save();
38+
$policyAcceptance->refresh();
39+
40+
$this->assertDatabaseHas('policy_acceptances', [
41+
'user_id' => $this->userId,
42+
'policy_id' => $this->policyId,
43+
]);
44+
45+
$this->assertNotEmpty($policyAcceptance->accepted_at);
46+
$this->assertInstanceOf(CarbonImmutable::class, $policyAcceptance->accepted_at);
47+
}
48+
49+
public function testCreateFailsIfAcceptedAtIsMissing() {
50+
$this->expectException(RuntimeException::class);
51+
PolicyAcceptance::create([
52+
'user_id' => $this->userId,
53+
'policy_id' => $this->policyId,
54+
]);
55+
}
56+
57+
public function testCreateFailsIfAcceptedAtIsNull() {
58+
$this->expectException(RuntimeException::class);
59+
PolicyAcceptance::create([
60+
'user_id' => $this->userId,
61+
'policy_id' => $this->policyId,
62+
'accepted_at' => null,
63+
]);
64+
}
65+
66+
public function testUserAcceptingSamePolicyTwiceFails() {
67+
PolicyAcceptance::create([
68+
'user_id' => $this->userId,
69+
'policy_id' => $this->policyId,
70+
'accepted_at' => CarbonImmutable::now()->subSeconds(2),
71+
]);
72+
$this->expectException(RuntimeException::class);
73+
PolicyAcceptance::create([
74+
'user_id' => $this->userId,
75+
'policy_id' => $this->policyId,
76+
'accepted_at' => CarbonImmutable::now(),
77+
]);
78+
}
79+
}

tests/PolicyTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace Tests;
4+
5+
use App\Policy;
6+
use Carbon\CarbonImmutable;
7+
use Illuminate\Foundation\Testing\RefreshDatabase;
8+
9+
class PolicyTest extends TestCase {
10+
use RefreshDatabase;
11+
12+
public function testCreatesSuccessfully(): void {
13+
Policy::create([
14+
'policy_type' => 'terms-of-use',
15+
'active_from' => CarbonImmutable::createMidnightDate(2025, 4, 1),
16+
'content_vue_file' => 'terms-of-use/example.vue',
17+
]);
18+
19+
$this->assertDatabaseHas('policies', [
20+
'policy_type' => 'terms-of-use',
21+
'active_from' => '2025-04-01',
22+
'content_vue_file' => 'terms-of-use/example.vue',
23+
]);
24+
}
25+
}

0 commit comments

Comments
 (0)