diff --git a/app/Enums/WikiSuspensionActionReason.php b/app/Enums/WikiSuspensionActionReason.php new file mode 100644 index 000000000..57b58bb5c --- /dev/null +++ b/app/Enums/WikiSuspensionActionReason.php @@ -0,0 +1,16 @@ +|WikiSuspensionAction newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction query() + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction whereActiveFrom($value) + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction whereActorUserId($value) + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction whereReason($value) + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction whereReviewSubmissionActionId($value) + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction whereSuspensionType($value) + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction whereWikiId($value) + * + * @mixin \Eloquent + */ +class WikiSuspensionAction extends Model { + /** @use HasFactory */ + use HasFactory; + + protected function casts() { + return [ + 'suspension_type' => WikiSuspensionActionType::class, + 'reason' => WikiSuspensionActionReason::class, + // cast to `CarbonImmutable` until we default to using `CarbonImmutable` globally in T430656 + 'active_from' => 'immutable_datetime', + 'created_at' => 'immutable_datetime', + 'updated_at' => 'immutable_datetime', + ]; + } + + public function actor(): BelongsTo { + return $this->belongsTo(User::class, 'actor_user_id'); + } + + public function reviewSubmissionAction(): BelongsTo { + return $this->belongsTo(ReviewSubmissionAction::class); + } + + public function wiki(): BelongsTo { + return $this->belongsTo(Wiki::class); + } +} diff --git a/database/factories/WikiSuspensionActionFactory.php b/database/factories/WikiSuspensionActionFactory.php new file mode 100644 index 000000000..d26432784 --- /dev/null +++ b/database/factories/WikiSuspensionActionFactory.php @@ -0,0 +1,46 @@ + + */ +class WikiSuspensionActionFactory extends Factory { + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array { + return [ + 'suspension_type' => WikiSuspensionActionType::SUSPEND, + 'actor_user_id' => null, + 'wiki_id' => Wiki::factory(), + 'review_submission_action_id' => null, + 'active_from' => now(), + 'reason' => WikiSuspensionActionReason::AUTOMATIC_EXPIRY, + ]; + } + + public function forActor(User $actor): static { + return $this->for($actor, 'actor'); + } + + public function resultingFrom(ReviewSubmissionAction $reviewSubmissionAction): static { + return $this->state([ + 'review_submission_action_id' => $reviewSubmissionAction->id, + 'actor_user_id' => $reviewSubmissionAction->actor_user_id, + 'wiki_id' => $reviewSubmissionAction->reviewSubmission->wiki_id, + ]); + } +} diff --git a/database/migrations/2026_10_09_000001_create_wiki_suspension_actions_table.php b/database/migrations/2026_10_09_000001_create_wiki_suspension_actions_table.php new file mode 100644 index 000000000..88ff4330b --- /dev/null +++ b/database/migrations/2026_10_09_000001_create_wiki_suspension_actions_table.php @@ -0,0 +1,71 @@ +id(); + + $table->enum('suspension_type', ['suspend', 'unsuspend']); + + // Can't use the `foreignId()` method because the `users.id` column isn't an unsigned big integer + $table->unsignedInteger('actor_user_id') + // Some suspensions will be created automatically (e.g. when a wiki is created) + // where there is no sensible user to be linked with the suspension action + ->nullable(); + $table->foreign('actor_user_id')->references('id')->on('users') + // Be explicit about the type of constraint restrictions, + // don't rely on the database's defaults as they could change + ->restrictOnUpdate() + ->restrictOnDelete(); + + // An `actor_user_role` column is not required as only 'review_committee_admin's can suspend a wiki + + // Can't use the `foreignId()` method because the `wiki.id` column isn't an unsigned big integer + $table->unsignedInteger('wiki_id'); + $table->foreign('wiki_id')->references('id')->on('wikis') + // Be explicit about the type of constraint restrictions, + // don't rely on the database's defaults as they could change + ->restrictOnUpdate() + ->restrictOnDelete(); + + $table->foreignId('review_submission_action_id') + ->nullable() + ->unique() + ->constrained() + // Be explicit about the type of constraint restrictions, + // don't rely on the database's defaults as they could change + ->restrictOnUpdate() + ->restrictOnDelete(); + + $table->dateTime('active_from'); + + $table->enum('reason', [ + // for suspension_type = suspend + 'manual_expiry', + 'automatic_expiry', + 'tou_violation', + 'hp_violation', + // for suspension_type = unsuspend + 'indefinitely_allowed', + ]); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::dropIfExists('wiki_suspension_actions'); + } +}; diff --git a/tests/Feature/Models/WikiSuspensionActionTest.php b/tests/Feature/Models/WikiSuspensionActionTest.php new file mode 100644 index 000000000..49e9fc401 --- /dev/null +++ b/tests/Feature/Models/WikiSuspensionActionTest.php @@ -0,0 +1,146 @@ +make(); + + $this->assertTrue($suspensionAction->save()); + } + + /** + * A wiki suspension action can retrieve the actor who performed it. + */ + public function testRetrievingActor(): void { + $actor = User::factory()->create(); + + $suspensionActionId = WikiSuspensionAction::factory() + ->forActor($actor) + ->create() + ->id; + + // Retrieve the model from the database + $suspensionAction = WikiSuspensionAction::findOrFail($suspensionActionId); + + $this->assertTrue($suspensionAction->actor->is($actor)); + } + + /** + * A wiki suspension action can retrieve the wiki it belongs to. + */ + public function testRetrievingWiki(): void { + $wiki = Wiki::factory()->create(); + + $suspensionActionId = WikiSuspensionAction::factory() + ->for($wiki) + ->create() + ->id; + + // Retrieve the model from the database + $suspensionAction = WikiSuspensionAction::findOrFail($suspensionActionId); + + $this->assertTrue($suspensionAction->wiki->is($wiki)); + } + + /** + * A wiki suspension action can retrieve its related review submission action. + */ + public function testRetrievingReviewSubmissionAction(): void { + $reviewSubmission = ReviewSubmission::factory() + ->hasActions(['type' => ReviewSubmissionActionType::SUBMITTED, 'actor_user_role' => UserRole::WIKI_MANAGER]) + ->hasActions(['type' => ReviewSubmissionActionType::REVIEW_STARTED, 'actor_user_role' => UserRole::REVIEW_COMMITTEE_ADMIN]) + ->hasActions(['type' => ReviewSubmissionActionType::APPROVED, 'actor_user_role' => UserRole::REVIEW_COMMITTEE_ADMIN]) + ->create(); + + $reviewSubmissionAction = $reviewSubmission->latestAction; + $suspensionActionId = WikiSuspensionAction::factory() + ->resultingFrom($reviewSubmissionAction) + ->create() + ->id; + + // Retrieve the model from the database + $suspensionAction = WikiSuspensionAction::findOrFail($suspensionActionId); + + $this->assertTrue($suspensionAction->reviewSubmissionAction->is($reviewSubmissionAction)); + $this->assertTrue($suspensionAction->wiki->is($reviewSubmission->wiki)); + } + + /** + * Only one wiki suspension action can be linked to a review submission action. + */ + public function testOnlyOneSuspensionActionCanBeLinkedToAReviewSubmissionAction(): void { + $reviewSubmissionAction = ReviewSubmissionAction::factory()->create(); + + WikiSuspensionAction::factory() + ->resultingFrom($reviewSubmissionAction) + ->create([ + 'active_from' => now()->subDay(), + 'reason' => WikiSuspensionActionReason::HP_VIOLATION, + ]); + + $this->expectException(QueryException::class); + + WikiSuspensionAction::factory() + ->resultingFrom($reviewSubmissionAction) + ->create([ + 'active_from' => now(), + 'reason' => WikiSuspensionActionReason::HP_VIOLATION, + ]); + } + + /** + * A wiki suspension action casts its suspension type to WikiSuspensionActionType. + */ + public function testSuspensionTypeCastsCorrectly(): void { + $suspensionAction = WikiSuspensionAction::factory()->create([ + 'suspension_type' => WikiSuspensionActionType::UNSUSPEND, + 'reason' => WikiSuspensionActionReason::INDEFINITELY_ALLOWED, + ]); + + $this->assertSame(WikiSuspensionActionType::UNSUSPEND, $suspensionAction->suspension_type); + } + + /** + * A wiki suspension action casts its reason to WikiSuspensionActionReason. + */ + public function testReasonCastsCorrectly(): void { + $suspensionAction = WikiSuspensionAction::factory()->create([ + 'reason' => WikiSuspensionActionReason::TOU_VIOLATION, + ]); + + $this->assertSame(WikiSuspensionActionReason::TOU_VIOLATION, $suspensionAction->reason); + } + + /** + * A wiki suspension action casts its active date and timestamps to CarbonImmutable. + */ + public function testDatesAndTimesCastCorrectly(): void { + $suspensionAction = WikiSuspensionAction::factory()->create(); + + $this->assertInstanceOf(CarbonImmutable::class, $suspensionAction->active_from); + $this->assertInstanceOf(CarbonImmutable::class, $suspensionAction->created_at); + $this->assertInstanceOf(CarbonImmutable::class, $suspensionAction->updated_at); + } +}