Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/Enums/WikiSuspensionActionReason.php
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';

Copy link
Copy Markdown
Contributor

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

case POSTPONED_EXPIRY = 'postponed_expiry';
case TOU_VIOLATION = 'tou_violation';
case HP_VIOLATION = 'hp_violation';
}
10 changes: 10 additions & 0 deletions app/Enums/WikiSuspensionActionType.php
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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea to also make this an enum

case UNSUSPEND = 'unsuspend';
}
58 changes: 58 additions & 0 deletions app/WikiSuspensionAction.php
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);
}
}
49 changes: 49 additions & 0 deletions database/factories/WikiSuspensionActionFactory.php
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 indefinitely_allowed.

my preference is we add indefinitely_allowed.

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');
}
};
120 changes: 120 additions & 0 deletions tests/Feature/Models/WikiSuspensionActionTest.php
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);

Comment thread
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);
}
}
Loading