From 6e7cde09ff8078e3ead15862e7ea5cc547579f5d Mon Sep 17 00:00:00 2001 From: Ollie Date: Mon, 12 Oct 2026 14:18:33 +0100 Subject: [PATCH] Create initial migration, model, factory, and test for suspending wikis The relationship between the `review_submission_actions` and `wiki_suspension_actions` tables could be represented as a foreign key column on either table. Bug: T440875 --- app/Enums/WikiSuspensionActionReason.php | 12 ++ app/Enums/WikiSuspensionActionType.php | 10 ++ app/WikiSuspensionAction.php | 58 +++++++++ .../factories/WikiSuspensionActionFactory.php | 49 +++++++ ...1_create_wiki_suspension_actions_table.php | 80 ++++++++++++ .../Models/WikiSuspensionActionTest.php | 120 ++++++++++++++++++ 6 files changed, 329 insertions(+) create mode 100644 app/Enums/WikiSuspensionActionReason.php create mode 100644 app/Enums/WikiSuspensionActionType.php create mode 100644 app/WikiSuspensionAction.php create mode 100644 database/factories/WikiSuspensionActionFactory.php create mode 100644 database/migrations/2026_10_09_000001_create_wiki_suspension_actions_table.php create mode 100644 tests/Feature/Models/WikiSuspensionActionTest.php diff --git a/app/Enums/WikiSuspensionActionReason.php b/app/Enums/WikiSuspensionActionReason.php new file mode 100644 index 000000000..26fc55754 --- /dev/null +++ b/app/Enums/WikiSuspensionActionReason.php @@ -0,0 +1,12 @@ +|WikiSuspensionAction newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|WikiSuspensionAction query() + * + * @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..61f17e21b --- /dev/null +++ b/database/factories/WikiSuspensionActionFactory.php @@ -0,0 +1,49 @@ + + */ +class WikiSuspensionActionFactory extends Factory { + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array { + return [ + // there are too many possible states to provide sensible defaults + // use case specific states can be created with separate helper functions + // OR + 'suspension_type' => WikiSuspensionActionType::SUSPEND, + 'actor_user_id' => null, + 'wiki_id' => Wiki::factory(), + 'review_submission_action_id' => null, + 'active_from' => now(), + 'reason' => WikiSuspensionActionReason::DEFAULT_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..143d3520d --- /dev/null +++ b/database/migrations/2026_10_09_000001_create_wiki_suspension_actions_table.php @@ -0,0 +1,80 @@ +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 this 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 permitted actors should be able to 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(); + + // This relationship could have equally be added to the `review_submission_action` table. + // We don't currently know which table would have the fewest `null` values for this relationship. + // Adding it here is the simplest option as it doesn't require a additional database migration. + // It is likely that the ReviewSubmissionAction is created before the WikiSuspensionAction, so keeping the + // relationship on this table will mean it doesn't need to be updated once the WikiSuspensionAction exits. + // + // TODO: is this a one-to-one or a one-to-many relationship? + // - if it is a one-to-one relationship then the foreign key column can be added to either column + // * a unique constraint should probably also be added to enforce this one-to-one relationship + // - if it is a one-to-many relationship then it makes sense for the foreign key column to exist on the + // "many" side of the relationship to avoid needing a separate pivot/junction table + // - imagined use case that would makes this a one-to-any relationship: + // * review submission is rejected with a 14-day grace period to fix issues + // * WikiManager requests an extra 14-days to completed changes made by review committee + // * PM creates another "suspend" record linked to the same rejected review submission action + $table->foreignId('review_submission_action_id') + ->nullable() + ->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'); + + // TODO: what should this be when suspension_type = unsuspend? make ->nullable() or add extra enums? + // TODO: what reason for "(re-)review required in X days"? + // TODO: what reason for the "review denied but with an X days grace period to fix issues"? + $table->enum('reason', ['default_expiry', 'postponed_expiry', 'tou_violation', 'hp_violation']); + + $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..cd5d21f6f --- /dev/null +++ b/tests/Feature/Models/WikiSuspensionActionTest.php @@ -0,0 +1,120 @@ +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)); + } + + /** + * A wiki suspension action casts its suspension type to WikiSuspensionActionType. + */ + public function testSuspensionTypeCastsCorrectly(): void { + $suspensionAction = WikiSuspensionAction::factory()->create([ + 'suspension_type' => WikiSuspensionActionType::UNSUSPEND, + ]); + + $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); + } +}