Skip to content
Merged
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
128 changes: 128 additions & 0 deletions tests/Feature/Controllers/Api/CommitPinControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

declare(strict_types=1);

use App\Models\Commit;
use App\Models\User;

beforeEach(function (): void {
$this->user = User::factory()->create();
$this->keyPair = apiKeyPairFor($this->user);
});

it('pins an own top-level commit and answers with it pinned', function (string $status): void {
$commit = Commit::factory()->for($this->user)->create(['status' => $status]);

$response = signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $commit));

$response->assertOk()
->assertJsonPath('data.id', $commit->id)
->assertJsonPath('data.is_pinned', true);

expect($this->user->fresh()->pinned_commit_id)->toBe($commit->id);
})->with(['approved', 'pending', 'rejected']);

it('replaces the commit pinned before', function (): void {
$first = Commit::factory()->for($this->user)->create();
$replacement = Commit::factory()->for($this->user)->create();

signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $first))->assertOk();
signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $replacement))
->assertOk()
->assertJsonPath('data.is_pinned', true);

expect($this->user->fresh()->pinned_commit_id)->toBe($replacement->id);
});

it('unpins idempotently and answers with it unpinned', function (): void {
$commit = Commit::factory()->for($this->user)->create();
$this->user->update(['pinned_commit_id' => $commit->id]);

signedApiRequest($this->keyPair, 'DELETE', route('api-commit-pin.destroy', $commit))
->assertOk()
->assertJsonPath('data.id', $commit->id)
->assertJsonPath('data.is_pinned', false);
signedApiRequest($this->keyPair, 'DELETE', route('api-commit-pin.destroy', $commit))->assertOk();

expect($this->user->fresh()->pinned_commit_id)->toBeNull();
});

it('does not clear a replacement on a stale unpin', function (): void {
$first = Commit::factory()->for($this->user)->create();
$replacement = Commit::factory()->for($this->user)->create();
$this->user->update(['pinned_commit_id' => $replacement->id]);

signedApiRequest($this->keyPair, 'DELETE', route('api-commit-pin.destroy', $first))
->assertOk()
->assertJsonPath('data.is_pinned', false);

expect($this->user->fresh()->pinned_commit_id)->toBe($replacement->id);
});

it('forbids changing the pin of another author, even for an admin', function (string $method, string $route): void {
config(['app.admins' => ['admin']]);
$admin = User::factory()->create(['handle' => 'admin']);
$commit = Commit::factory()->create();
$commit->user->update(['pinned_commit_id' => $commit->id]);

signedApiRequest(apiKeyPairFor($admin), $method, route($route, $commit))->assertForbidden();

expect($commit->user->fresh()->pinned_commit_id)->toBe($commit->id)
->and($admin->fresh()->pinned_commit_id)->toBeNull();
})->with([
'pin' => ['PUT', 'api-commit-pin.update'],
'unpin' => ['DELETE', 'api-commit-pin.destroy'],
]);

it('forbids pinning or unpinning a reply', function (string $method, string $route): void {
$reply = Commit::factory()->for($this->user)->for(Commit::factory(), 'parent')->create();

signedApiRequest($this->keyPair, $method, route($route, $reply))->assertForbidden();

expect($this->user->fresh()->pinned_commit_id)->toBeNull();
})->with([
'pin' => ['PUT', 'api-commit-pin.update'],
'unpin' => ['DELETE', 'api-commit-pin.destroy'],
]);

it('hides the commits the user may not see', function (string $method, string $route, Closure $commit): void {
$commit = $commit($this->user);

signedApiRequest($this->keyPair, $method, route($route, $commit))->assertNotFound();

expect($this->user->fresh()->pinned_commit_id)->toBeNull();
})->with([
'pin' => ['PUT', 'api-commit-pin.update'],
'unpin' => ['DELETE', 'api-commit-pin.destroy'],
])->with([
'pending' => fn (User $user): Commit => Commit::factory()->pending()->create(),
'across a block' => function (User $user): Commit {
$commit = Commit::factory()->create();
$user->blocking()->attach($commit->user);

return $commit;
},
]);

it('cannot change the pin of a deleted commit', function (string $method, string $route): void {
$commit = Commit::factory()->for($this->user)->create();
$commit->delete();

signedApiRequest($this->keyPair, $method, route($route, $commit))->assertNotFound();

expect($this->user->fresh()->pinned_commit_id)->toBeNull();
})->with([
'pin' => ['PUT', 'api-commit-pin.update'],
'unpin' => ['DELETE', 'api-commit-pin.destroy'],
]);

it('requires a signed request', function (string $method, string $route): void {
$commit = Commit::factory()->for($this->user)->create();

$this->json($method, route($route, $commit))->assertUnauthorized();

expect($this->user->fresh()->pinned_commit_id)->toBeNull();
})->with([
'pin' => ['PUT', 'api-commit-pin.update'],
'unpin' => ['DELETE', 'api-commit-pin.destroy'],
]);
93 changes: 93 additions & 0 deletions tests/Feature/Controllers/Api/CommitRestorationControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

use App\Actions\DeleteCommit;
use App\Enums\CommitStatus;
use App\Models\Commit;
use App\Models\User;

beforeEach(function (): void {
$this->user = User::factory()->create();
$this->keyPair = apiKeyPairFor($this->user);
});

it('restores a deleted commit within thirty seconds and answers with it', function (CommitStatus $status): void {
$commit = Commit::factory()->for($this->user)->create(['status' => $status]);
resolve(DeleteCommit::class)->handle($commit);
$this->travel(29)->seconds();

$response = signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit));

$response->assertOk()
->assertJsonPath('data.id', $commit->id)
->assertJsonPath('data.status', $status->value);

$this->assertNotSoftDeleted($commit);
})->with(CommitStatus::cases());

it('answers with the restored commit as the commit endpoint shows it', function (): void {
$commit = Commit::factory()->for($this->user)->create();
Commit::factory()->for($commit, 'parent')->create();
resolve(DeleteCommit::class)->handle($commit);

$response = signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit));

$response->assertOk()
->assertExactJson(['data' => signedApiRequest($this->keyPair, 'GET', route('api-commit.show', $commit))->json('data')]);
});

it('rejects undo at and after the deadline', function (int $seconds): void {
$this->travelTo(now()->startOfSecond());
$commit = Commit::factory()->for($this->user)->create();
resolve(DeleteCommit::class)->handle($commit);
$this->travel($seconds)->seconds();

signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit))
->assertUnprocessable()
->assertJsonValidationErrors(['undo' => 'The 30-second undo window has expired.']);

$this->assertSoftDeleted($commit);
})->with([30, 31, 3600]);

it('rejects a commit that is not deleted, and a repeated undo', function (): void {
$commit = Commit::factory()->for($this->user)->create();

signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit))
->assertUnprocessable()
->assertJsonValidationErrors(['undo' => 'This commit is no longer deleted.']);

resolve(DeleteCommit::class)->handle($commit);

signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit))->assertOk();
signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit))
->assertUnprocessable()
->assertJsonValidationErrors(['undo' => 'This commit is no longer deleted.']);

$this->assertNotSoftDeleted($commit);
});

it('hides the deleted commits of others', function (): void {
$commit = Commit::factory()->create();
resolve(DeleteCommit::class)->handle($commit);

signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit))->assertNotFound();

$this->assertSoftDeleted($commit);
});

it('cannot restore a force deleted commit', function (): void {
$commit = Commit::factory()->for($this->user)->create();
$commit->forceDelete();

signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit))->assertNotFound();
});

it('requires a signed request', function (): void {
$commit = Commit::factory()->for($this->user)->create();
resolve(DeleteCommit::class)->handle($commit);

$this->postJson(route('api-commit-restoration.store', $commit))->assertUnauthorized();

$this->assertSoftDeleted($commit);
});
90 changes: 90 additions & 0 deletions tests/Feature/Controllers/Api/CommitSearchControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

use App\Models\Commit;
use App\Models\User;
use Illuminate\Support\Facades\Http;

beforeEach(function (): void {
config(['ai.providers.openai.key' => 'sk-test']);

$this->user = User::factory()->create();
$this->keyPair = apiKeyPairFor($this->user);
});

it('returns the commits nearest the term, closest first, as the commit endpoint shows them', function (): void {
fakeSearch();

$closest = Commit::factory()->create(['body' => 'Shipped a new feature', 'embedding' => embeddingVector(0)]);
$closest->stars()->attach($this->user);
$other = Commit::factory()->create(['body' => 'Something else', 'embedding' => embeddingVector(1)]);

$response = signedApiRequest($this->keyPair, 'GET', route('api-commit-search.index', ['q' => 'feature']));

$response->assertOk()
->assertJsonPath('data.*.id', [$closest->id, $other->id])
->assertJsonPath('data.0.starred', true)
->assertJsonPath('data.0.author.handle', $closest->user->handle)
->assertJsonMissingPath('data.0.embedding');
});

it('drops the candidates the decision model calls irrelevant', function (): void {
fakeSearch([true, false]);

$relevant = Commit::factory()->create(['embedding' => embeddingVector(0)]);
Commit::factory()->create(['embedding' => embeddingVector(1)]);

signedApiRequest($this->keyPair, 'GET', route('api-commit-search.index', ['q' => 'indexing']))
->assertOk()
->assertJsonPath('data.*.id', [$relevant->id]);
});

it('returns no more than the limit asked for, and 20 by default', function (?int $limit, int $count): void {
fakeSearch();

Commit::factory()->count(21)->create(['embedding' => embeddingVector(0)]);

signedApiRequest($this->keyPair, 'GET', route('api-commit-search.index', array_filter(['q' => 'feature', 'limit' => $limit])))
->assertOk()
->assertJsonCount($count, 'data');
})->with([
'the default' => [null, 20],
'a limit' => [2, 2],
]);

it('leaves out the commits of people the user blocked', function (): void {
fakeSearch();

$visible = Commit::factory()->create(['embedding' => embeddingVector(0)]);
$blocked = Commit::factory()->create(['embedding' => embeddingVector(0)]);
$this->user->blocking()->attach($blocked->user);

signedApiRequest($this->keyPair, 'GET', route('api-commit-search.index', ['q' => 'feature']))
->assertOk()
->assertJsonPath('data.*.id', [$visible->id]);
});

it('rejects short or long terms and out-of-range limits without asking openai', function (array $query, string $field): void {
Http::fake();

signedApiRequest($this->keyPair, 'GET', route('api-commit-search.index', $query))
->assertUnprocessable()
->assertJsonValidationErrors($field);

Http::assertNothingSent();
})->with([
'no term' => [[], 'q'],
'a term under 3 characters' => [['q' => 'ab'], 'q'],
'a term over 100 characters' => [['q' => str_repeat('a', 101)], 'q'],
'a limit of zero' => [['q' => 'feature', 'limit' => 0], 'limit'],
'a limit over 100' => [['q' => 'feature', 'limit' => 101], 'limit'],
]);

it('requires a signed request', function (): void {
Http::fake();

$this->getJson(route('api-commit-search.index', ['q' => 'feature']))->assertUnauthorized();

Http::assertNothingSent();
});
Loading
Loading