Repository navigation
Create initial migration, model, factory, and test for suspending wikis #1294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Enums; | ||
|
|
||
| enum WikiSuspensionActionReason: string { | ||
| case DEFAULT_EXPIRY = 'default_expiry'; | ||
| case POSTPONED_EXPIRY = 'postponed_expiry'; | ||
| case TOU_VIOLATION = 'tou_violation'; | ||
| case HP_VIOLATION = 'hp_violation'; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Enums; | ||
|
|
||
| enum WikiSuspensionActionType: string { | ||
| case SUSPEND = 'suspend'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good idea to also make this an enum |
||
| case UNSUSPEND = 'unsuspend'; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App; | ||
|
|
||
| use App\Enums\WikiSuspensionActionReason; | ||
| use App\Enums\WikiSuspensionActionType; | ||
| use Carbon\CarbonImmutable; | ||
| use Database\Factories\WikiSuspensionActionFactory; | ||
| use Illuminate\Database\Eloquent\Factories\HasFactory; | ||
| use Illuminate\Database\Eloquent\Model; | ||
| use Illuminate\Database\Eloquent\Relations\BelongsTo; | ||
|
|
||
| /** | ||
| * @property WikiSuspensionActionType $suspension_type | ||
| * @property-read User|null $actor | ||
| * @property WikiSuspensionActionReason $reason | ||
| * @property CarbonImmutable $active_from | ||
| * @property CarbonImmutable $created_at | ||
| * @property CarbonImmutable $updated_at | ||
| * @property-read ReviewSubmissionAction|null $reviewSubmissionAction | ||
| * @property-read Wiki|null $wiki | ||
| * | ||
| * @method static \Database\Factories\WikiSuspensionActionFactory factory($count = null, $state = []) | ||
| * @method static \Illuminate\Database\Eloquent\Builder<static>|WikiSuspensionAction newModelQuery() | ||
| * @method static \Illuminate\Database\Eloquent\Builder<static>|WikiSuspensionAction newQuery() | ||
| * @method static \Illuminate\Database\Eloquent\Builder<static>|WikiSuspensionAction query() | ||
| * | ||
| * @mixin \Eloquent | ||
| */ | ||
| class WikiSuspensionAction extends Model { | ||
| /** @use HasFactory<WikiSuspensionActionFactory> */ | ||
| 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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Database\Factories; | ||
|
|
||
| use App\Enums\WikiSuspensionActionReason; | ||
| use App\Enums\WikiSuspensionActionType; | ||
| use App\ReviewSubmissionAction; | ||
| use App\User; | ||
| use App\Wiki; | ||
| use App\WikiSuspensionAction; | ||
| use Illuminate\Database\Eloquent\Factories\Factory; | ||
|
|
||
| /** | ||
| * @extends Factory<WikiSuspensionAction> | ||
| */ | ||
| class WikiSuspensionActionFactory extends Factory { | ||
| /** | ||
| * Define the model's default state. | ||
| * | ||
| * @return array<string, mixed> | ||
| */ | ||
| 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, | ||
| ]); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use Illuminate\Database\Migrations\Migration; | ||
| use Illuminate\Database\Schema\Blueprint; | ||
| use Illuminate\Support\Facades\Schema; | ||
|
|
||
| return new class() extends Migration { | ||
| /** | ||
| * Run the migrations. | ||
| */ | ||
| public function up(): void { | ||
| Schema::create('wiki_suspension_actions', function (Blueprint $table) { | ||
| $table->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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I favour a one-to-one model for now. Adding uniqueness does seem wise so we are explicit if we change it in future. Trying not to get philosophical: a second suspension action added later doesn't seem strongly connecting to a reviewsubmissionaction |
||
| // * 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'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My current thinking is that this should either be nullable or we add a field to the enum like my preference is we add This does annoyingly nudge me to thinking that the layout is still a bit ugly and keeping suspensions right next to unsuspensions isn't that elegant. I think we should go ahead anyway though. |
||
|
|
||
| // 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'); | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Tests\Feature\Models; | ||
|
|
||
| use App\Enums\ReviewSubmissionActionType; | ||
| use App\Enums\UserRole; | ||
| use App\Enums\WikiSuspensionActionReason; | ||
| use App\Enums\WikiSuspensionActionType; | ||
| use App\ReviewSubmission; | ||
| use App\User; | ||
| use App\Wiki; | ||
| use App\WikiSuspensionAction; | ||
| use Carbon\CarbonImmutable; | ||
| use Illuminate\Foundation\Testing\DatabaseTransactions; | ||
| use Tests\TestCase; | ||
|
|
||
| class WikiSuspensionActionTest extends TestCase { | ||
| use DatabaseTransactions; | ||
|
|
||
| /** | ||
| * A wiki suspension action can be created and saved to the database. | ||
| */ | ||
| public function testCreation(): void { | ||
| $suspensionAction = WikiSuspensionAction::factory()->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); | ||
|
|
||
|
dati18 marked this conversation as resolved.
|
||
| $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); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know we were originally not keen but I think keeping this here and inserting it is expedient