479 lines
17 KiB
PHP
479 lines
17 KiB
PHP
<?php
|
|
|
|
use App\Enums\IntegrationRequestStatus;
|
|
use App\Models\IntegrationRequest;
|
|
use App\Models\User;
|
|
|
|
// With subscriptions enabled, a freshly created user is on the free plan
|
|
// (three monthly actions). Subscribed users get the pro quota of nine.
|
|
beforeEach(function () {
|
|
config(['subscriptions.enabled' => true]);
|
|
});
|
|
|
|
test('guests cannot access the integration requests page', function () {
|
|
$this->get('/integration-requests')->assertRedirect();
|
|
});
|
|
|
|
test('the integration requests url renders the dashboard with the drawer open', function () {
|
|
// The dashboard route is paywalled; keep this user out of the paywall.
|
|
config(['subscriptions.enabled' => false]);
|
|
$user = User::factory()->onboarded()->create();
|
|
|
|
$this->actingAs($user)
|
|
->get('/integration-requests')
|
|
->assertOk()
|
|
->assertInertia(fn ($page) => $page
|
|
->component('dashboard')
|
|
->where('openIntegrationRequests', true)
|
|
);
|
|
});
|
|
|
|
test('the data endpoint returns the board state as json', function () {
|
|
$user = User::factory()->create();
|
|
IntegrationRequest::factory()->approved()->create();
|
|
|
|
$this->actingAs($user)
|
|
->getJson('/integration-requests/data')
|
|
->assertOk()
|
|
->assertJsonCount(1, 'requests')
|
|
->assertJsonPath('actionsRemaining', 3);
|
|
});
|
|
|
|
test('creating a request auto-votes it and costs two actions', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user)
|
|
->postJson('/integration-requests', [
|
|
'name' => 'Revolut',
|
|
'url' => 'https://revolut.com',
|
|
])
|
|
->assertCreated()
|
|
->assertJsonPath('actionsRemaining', 1)
|
|
->assertJsonPath('requests.0.votes_count', 1)
|
|
->assertJsonPath('requests.0.has_voted', true);
|
|
|
|
$this->assertDatabaseHas('integration_requests', [
|
|
'user_id' => $user->id,
|
|
'name' => 'Revolut',
|
|
'status' => 'pending',
|
|
]);
|
|
$this->assertDatabaseCount('integration_request_votes', 1);
|
|
});
|
|
|
|
test('a user cannot create a request with only one action left', function () {
|
|
$user = User::factory()->create();
|
|
IntegrationRequest::factory()->count(2)->create(['user_id' => $user->id]);
|
|
|
|
$this->actingAs($user)
|
|
->postJson('/integration-requests', ['name' => 'X', 'url' => 'https://x.com'])
|
|
->assertStatus(422);
|
|
});
|
|
|
|
test('requesting an integration requires a name and a valid url', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user)
|
|
->postJson('/integration-requests', ['name' => '', 'url' => 'not-a-url'])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors(['name', 'url']);
|
|
});
|
|
|
|
test('a user can vote multiple times on the same request', function () {
|
|
$user = User::factory()->create();
|
|
$request = IntegrationRequest::factory()->approved()->create();
|
|
|
|
$this->actingAs($user)
|
|
->postJson("/integration-requests/{$request->id}/vote")
|
|
->assertOk()
|
|
->assertJsonPath('requests.0.votes_count', 1)
|
|
->assertJsonPath('actionsRemaining', 2);
|
|
|
|
$this->actingAs($user)
|
|
->postJson("/integration-requests/{$request->id}/vote")
|
|
->assertOk()
|
|
->assertJsonPath('requests.0.votes_count', 2)
|
|
->assertJsonPath('actionsRemaining', 1);
|
|
|
|
expect($request->votes()->where('user_id', $user->id)->count())->toBe(2);
|
|
});
|
|
|
|
test('a user can remove a vote cast this month and recover the action', function () {
|
|
$user = User::factory()->create();
|
|
$request = IntegrationRequest::factory()->approved()->create();
|
|
|
|
$this->actingAs($user)
|
|
->postJson("/integration-requests/{$request->id}/vote")
|
|
->assertOk()
|
|
->assertJsonPath('actionsRemaining', 2);
|
|
|
|
$this->actingAs($user)
|
|
->deleteJson("/integration-requests/{$request->id}/vote")
|
|
->assertOk()
|
|
->assertJsonPath('requests.0.votes_count', 0)
|
|
->assertJsonPath('requests.0.can_unvote', false)
|
|
->assertJsonPath('actionsRemaining', 3);
|
|
|
|
$this->assertDatabaseMissing('integration_request_votes', [
|
|
'integration_request_id' => $request->id,
|
|
'user_id' => $user->id,
|
|
]);
|
|
});
|
|
|
|
test('a vote cast in a previous month cannot be removed', function () {
|
|
$user = User::factory()->create();
|
|
$request = IntegrationRequest::factory()->approved()->create();
|
|
|
|
$vote = $request->votes()->create(['user_id' => $user->id]);
|
|
$vote->forceFill(['created_at' => now()->subMonth()])->saveQuietly();
|
|
|
|
$this->actingAs($user)
|
|
->getJson('/integration-requests/data')
|
|
->assertJsonPath('requests.0.can_unvote', false);
|
|
|
|
$this->actingAs($user)
|
|
->deleteJson("/integration-requests/{$request->id}/vote")
|
|
->assertOk()
|
|
->assertJsonPath('requests.0.votes_count', 1);
|
|
|
|
$this->assertDatabaseHas('integration_request_votes', [
|
|
'id' => $vote->id,
|
|
]);
|
|
});
|
|
|
|
test('pro users get nine monthly actions', function () {
|
|
$user = User::factory()->create();
|
|
$user->subscriptions()->create([
|
|
'type' => 'default',
|
|
'stripe_id' => 'sub_pro123',
|
|
'stripe_status' => 'active',
|
|
'stripe_price' => 'price_pro123',
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->getJson('/integration-requests/data')
|
|
->assertOk()
|
|
->assertJsonPath('actionsRemaining', 9);
|
|
});
|
|
|
|
test('a user cannot exceed the monthly action limit', function () {
|
|
$user = User::factory()->create();
|
|
IntegrationRequest::factory()->count(3)->create(['user_id' => $user->id]);
|
|
|
|
$this->actingAs($user)
|
|
->postJson('/integration-requests', ['name' => 'X', 'url' => 'https://x.com'])
|
|
->assertStatus(422);
|
|
|
|
$other = IntegrationRequest::factory()->approved()->create();
|
|
|
|
$this->actingAs($user)
|
|
->postJson("/integration-requests/{$other->id}/vote")
|
|
->assertStatus(422);
|
|
});
|
|
|
|
test('the admin bypasses the monthly limit and their requests are auto-approved', function () {
|
|
config(['mail.admin_email' => 'admin@whisper.test']);
|
|
$admin = User::factory()->create(['email' => 'admin@whisper.test']);
|
|
IntegrationRequest::factory()->count(5)->create(['user_id' => $admin->id]);
|
|
|
|
$this->actingAs($admin)
|
|
->postJson('/integration-requests', ['name' => 'Revolut', 'url' => 'https://revolut.com'])
|
|
->assertCreated()
|
|
->assertJsonPath('actionsRemaining', 3);
|
|
|
|
$this->assertDatabaseHas('integration_requests', [
|
|
'user_id' => $admin->id,
|
|
'name' => 'Revolut',
|
|
'status' => IntegrationRequestStatus::Approved->value,
|
|
]);
|
|
|
|
$other = IntegrationRequest::factory()->approved()->create();
|
|
|
|
$this->actingAs($admin)
|
|
->postJson("/integration-requests/{$other->id}/vote")
|
|
->assertOk();
|
|
});
|
|
|
|
test('repeated voting stops once the monthly limit is reached', function () {
|
|
$user = User::factory()->create();
|
|
$request = IntegrationRequest::factory()->approved()->create();
|
|
|
|
foreach (range(1, 3) as $ignored) {
|
|
$this->actingAs($user)
|
|
->postJson("/integration-requests/{$request->id}/vote")
|
|
->assertOk();
|
|
}
|
|
|
|
$this->actingAs($user)
|
|
->postJson("/integration-requests/{$request->id}/vote")
|
|
->assertStatus(422);
|
|
|
|
expect($request->votes()->count())->toBe(3);
|
|
});
|
|
|
|
test('pending requests are only visible to their creator', function () {
|
|
$owner = User::factory()->create();
|
|
$viewer = User::factory()->create();
|
|
IntegrationRequest::factory()->create(['user_id' => $owner->id]);
|
|
|
|
$this->actingAs($viewer)
|
|
->getJson('/integration-requests/data')
|
|
->assertJsonCount(0, 'requests');
|
|
|
|
$this->actingAs($owner)
|
|
->getJson('/integration-requests/data')
|
|
->assertJsonCount(1, 'requests');
|
|
});
|
|
|
|
test('approved requests are visible to every user', function () {
|
|
IntegrationRequest::factory()->approved()->create();
|
|
|
|
$this->actingAs(User::factory()->create())
|
|
->getJson('/integration-requests/data')
|
|
->assertJsonCount(1, 'requests');
|
|
});
|
|
|
|
test('a user cannot vote on a pending request they do not own', function () {
|
|
$request = IntegrationRequest::factory()->create();
|
|
|
|
$this->actingAs(User::factory()->create())
|
|
->postJson("/integration-requests/{$request->id}/vote")
|
|
->assertNotFound();
|
|
});
|
|
|
|
test('the review command approves a pending request', function () {
|
|
$request = IntegrationRequest::factory()->create(['name' => 'Revolut']);
|
|
|
|
$this->artisan('integration-requests:review')
|
|
->expectsChoice(
|
|
"Review \"{$request->name}\" ({$request->url})",
|
|
'approve',
|
|
['approve', 'in progress', 'reject', 'not doable', 'done', 'skip'],
|
|
)
|
|
->assertSuccessful();
|
|
|
|
expect($request->fresh()->status)->toBe(IntegrationRequestStatus::Approved);
|
|
});
|
|
|
|
test('the review command rejects a pending request', function () {
|
|
$request = IntegrationRequest::factory()->create(['name' => 'Spam']);
|
|
|
|
$this->artisan('integration-requests:review')
|
|
->expectsChoice(
|
|
"Review \"{$request->name}\" ({$request->url})",
|
|
'reject',
|
|
['approve', 'in progress', 'reject', 'not doable', 'done', 'skip'],
|
|
)
|
|
->assertSuccessful();
|
|
|
|
expect($request->fresh()->status)->toBe(IntegrationRequestStatus::Rejected);
|
|
});
|
|
|
|
test('the review command reports when there is nothing to review', function () {
|
|
$this->artisan('integration-requests:review')
|
|
->expectsOutput('No pending integration requests.')
|
|
->assertSuccessful();
|
|
});
|
|
|
|
test('not doable requests are visible to everyone with their comment', function () {
|
|
IntegrationRequest::factory()->notDoable()->create([
|
|
'name' => 'Impossible Bank',
|
|
'comment' => 'They have no public API.',
|
|
]);
|
|
|
|
$this->actingAs(User::factory()->create())
|
|
->getJson('/integration-requests/data')
|
|
->assertOk()
|
|
->assertJsonCount(1, 'requests')
|
|
->assertJsonPath('requests.0.status', 'not_doable')
|
|
->assertJsonPath('requests.0.comment', 'They have no public API.');
|
|
});
|
|
|
|
test('not doable requests are listed after the rest', function () {
|
|
IntegrationRequest::factory()->notDoable()->create(['name' => 'Not doable']);
|
|
$approved = IntegrationRequest::factory()->approved()->create(['name' => 'Approved']);
|
|
|
|
$this->actingAs(User::factory()->create())
|
|
->getJson('/integration-requests/data')
|
|
->assertOk()
|
|
->assertJsonPath('requests.0.id', $approved->id)
|
|
->assertJsonPath('requests.1.name', 'Not doable');
|
|
});
|
|
|
|
test('rejected requests never appear in the list', function () {
|
|
IntegrationRequest::factory()->rejected()->create();
|
|
|
|
$this->actingAs(User::factory()->create())
|
|
->getJson('/integration-requests/data')
|
|
->assertOk()
|
|
->assertJsonCount(0, 'requests');
|
|
});
|
|
|
|
test('nobody can vote on a not doable request', function () {
|
|
$request = IntegrationRequest::factory()->notDoable()->create();
|
|
|
|
$this->actingAs(User::factory()->create())
|
|
->postJson("/integration-requests/{$request->id}/vote")
|
|
->assertNotFound();
|
|
|
|
$owner = User::factory()->create();
|
|
$ownRequest = IntegrationRequest::factory()->notDoable()->create(['user_id' => $owner->id]);
|
|
|
|
$this->actingAs($owner)
|
|
->postJson("/integration-requests/{$ownRequest->id}/vote")
|
|
->assertNotFound();
|
|
});
|
|
|
|
test('a vote on a request later marked not doable cannot be removed', function () {
|
|
$user = User::factory()->create();
|
|
$request = IntegrationRequest::factory()->approved()->create();
|
|
$request->votes()->create(['user_id' => $user->id]);
|
|
|
|
$request->update(['status' => IntegrationRequestStatus::NotDoable]);
|
|
|
|
$this->actingAs($user)
|
|
->deleteJson("/integration-requests/{$request->id}/vote")
|
|
->assertNotFound();
|
|
|
|
expect($request->votes()->count())->toBe(1);
|
|
});
|
|
|
|
test('the review command marks a request as not doable with a comment', function () {
|
|
$request = IntegrationRequest::factory()->create(['name' => 'Hard Bank']);
|
|
|
|
$this->artisan('integration-requests:review')
|
|
->expectsChoice(
|
|
"Review \"{$request->name}\" ({$request->url})",
|
|
'not doable',
|
|
['approve', 'in progress', 'reject', 'not doable', 'done', 'skip'],
|
|
)
|
|
->expectsQuestion('Why is this integration not doable? (shown to users)', 'No public API available.')
|
|
->assertSuccessful();
|
|
|
|
$request->refresh();
|
|
expect($request->status)->toBe(IntegrationRequestStatus::NotDoable);
|
|
expect($request->comment)->toBe('No public API available.');
|
|
});
|
|
|
|
test('the review command with --all moves any request to a new status with a comment', function () {
|
|
$request = IntegrationRequest::factory()->approved()->create(['name' => 'Revolut']);
|
|
|
|
$this->artisan('integration-requests:review --all')
|
|
->expectsQuestion('Which request do you want to update? (number)', '1')
|
|
->expectsChoice(
|
|
'New status for "Revolut"',
|
|
'in progress',
|
|
['approve', 'in progress', 'reject', 'not doable', 'done'],
|
|
)
|
|
->expectsQuestion('Add a comment for this request (optional, shown to users)', 'Building it now.')
|
|
->assertSuccessful();
|
|
|
|
$request->refresh();
|
|
expect($request->status)->toBe(IntegrationRequestStatus::InProgress);
|
|
expect($request->comment)->toBe('Building it now.');
|
|
});
|
|
|
|
test('in progress requests are visible to everyone with their comment', function () {
|
|
IntegrationRequest::factory()->inProgress()->create([
|
|
'name' => 'Soon Bank',
|
|
'comment' => 'Working on it.',
|
|
]);
|
|
|
|
$this->actingAs(User::factory()->create())
|
|
->getJson('/integration-requests/data')
|
|
->assertOk()
|
|
->assertJsonCount(1, 'requests')
|
|
->assertJsonPath('requests.0.status', 'in_progress')
|
|
->assertJsonPath('requests.0.comment', 'Working on it.');
|
|
});
|
|
|
|
test('a user can vote on an in progress request', function () {
|
|
$request = IntegrationRequest::factory()->inProgress()->create();
|
|
|
|
$this->actingAs(User::factory()->create())
|
|
->postJson("/integration-requests/{$request->id}/vote")
|
|
->assertOk();
|
|
|
|
$this->assertDatabaseHas('integration_request_votes', [
|
|
'integration_request_id' => $request->id,
|
|
]);
|
|
});
|
|
|
|
test('done requests are visible to everyone without a comment', function () {
|
|
IntegrationRequest::factory()->done()->create(['name' => 'Shipped Bank']);
|
|
|
|
$this->actingAs(User::factory()->create())
|
|
->getJson('/integration-requests/data')
|
|
->assertOk()
|
|
->assertJsonCount(1, 'requests')
|
|
->assertJsonPath('requests.0.status', 'done')
|
|
->assertJsonPath('requests.0.comment', null);
|
|
});
|
|
|
|
test('done requests are listed after the rest', function () {
|
|
IntegrationRequest::factory()->done()->create(['name' => 'Done']);
|
|
$approved = IntegrationRequest::factory()->approved()->create(['name' => 'Approved']);
|
|
|
|
$this->actingAs(User::factory()->create())
|
|
->getJson('/integration-requests/data')
|
|
->assertOk()
|
|
->assertJsonPath('requests.0.id', $approved->id)
|
|
->assertJsonPath('requests.1.name', 'Done');
|
|
});
|
|
|
|
test('nobody can vote on a done request', function () {
|
|
$request = IntegrationRequest::factory()->done()->create();
|
|
|
|
$this->actingAs(User::factory()->create())
|
|
->postJson("/integration-requests/{$request->id}/vote")
|
|
->assertNotFound();
|
|
});
|
|
|
|
test('a vote on a request later marked done cannot be removed', function () {
|
|
$user = User::factory()->create();
|
|
$request = IntegrationRequest::factory()->approved()->create();
|
|
$request->votes()->create(['user_id' => $user->id]);
|
|
|
|
$request->update(['status' => IntegrationRequestStatus::Done]);
|
|
|
|
$this->actingAs($user)
|
|
->deleteJson("/integration-requests/{$request->id}/vote")
|
|
->assertNotFound();
|
|
|
|
expect($request->votes()->count())->toBe(1);
|
|
});
|
|
|
|
test('the review command marks a request as done and drops its comment', function () {
|
|
$request = IntegrationRequest::factory()->inProgress()->create([
|
|
'name' => 'Revolut',
|
|
'comment' => 'Working on it.',
|
|
]);
|
|
|
|
$this->artisan('integration-requests:review --all')
|
|
->expectsQuestion('Which request do you want to update? (number)', '1')
|
|
->expectsChoice(
|
|
'New status for "Revolut"',
|
|
'done',
|
|
['approve', 'in progress', 'reject', 'not doable', 'done'],
|
|
)
|
|
->assertSuccessful();
|
|
|
|
$request->refresh();
|
|
expect($request->status)->toBe(IntegrationRequestStatus::Done);
|
|
expect($request->comment)->toBeNull();
|
|
});
|
|
|
|
test('the review command still shows the author of requests whose account was deleted', function () {
|
|
$author = User::factory()->create(['email' => 'gone@whisper.test']);
|
|
IntegrationRequest::factory()->approved()->create(['user_id' => $author->id]);
|
|
|
|
// Soft-deleting the account would otherwise null the user relation and crash
|
|
// the table render; the command loads trashed authors so their email stays visible.
|
|
$author->delete();
|
|
|
|
$this->artisan('integration-requests:review --all')
|
|
->expectsOutputToContain('gone@whisper.test')
|
|
->expectsQuestion('Which request do you want to update? (number)', '0')
|
|
->expectsOutput('That number is not on the list.')
|
|
->assertFailed();
|
|
});
|