release/0.4.0 #60

Merged
myrmidex merged 30 commits from release/0.4.0 into main 2026-08-16 11:58:52 +02:00
7 changed files with 373 additions and 16 deletions
Showing only changes of commit 3a28988ae7 - Show all commits

View file

@ -4,11 +4,16 @@
namespace App\Models; namespace App\Models;
use Database\Factories\TrackerFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Tracker extends Model class Tracker extends Model
{ {
/** @use HasFactory<TrackerFactory> */
use HasFactory;
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'asset_id', 'asset_id',

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Tracker;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Tracker>
*/
class TrackerFactory extends Factory
{
protected $model = Tracker::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => fn () => User::default()->id,
'label' => 'Counter',
'unit' => 'units',
'count' => 0,
];
}
}

View file

@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class CountBackfillMigrationTest extends TestCase
{
use RefreshDatabase;
private const MIGRATION = __DIR__.'/../../database/migrations/2026_08_15_000002_add_count_to_trackers_drop_entries.php';
// Each require returns a fresh anonymous-class instance; there is no name to collide.
private function migration(): object
{
return require self::MIGRATION;
}
/**
* Rebuild the pre-migration shape: entries present, trackers without a count.
*/
private function revertToLedger(): void
{
$this->migration()->down();
}
private function makeTracker(): int
{
$userId = DB::table('users')->insertGetId([
'name' => 'Test',
'email' => 'ledger@example.test',
'password' => 'x',
'created_at' => now(),
'updated_at' => now(),
]);
return DB::table('trackers')->insertGetId([
'user_id' => $userId,
'label' => 'Counter',
'unit' => 'units',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function addEntry(int $trackerId, string $quantity): void
{
DB::table('entries')->insert([
'tracker_id' => $trackerId,
'date' => now()->toDateString(),
'quantity' => $quantity,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function countFor(int $trackerId): int
{
return (int) DB::table('trackers')->where('id', $trackerId)->value('count');
}
public function test_backfill_sums_entries_into_count(): void
{
$this->revertToLedger();
$trackerId = $this->makeTracker();
$this->addEntry($trackerId, '10');
$this->addEntry($trackerId, '15');
$this->migration()->up();
$this->assertSame(25, $this->countFor($trackerId));
}
public function test_backfill_rounds_fractional_totals(): void
{
$this->revertToLedger();
$trackerId = $this->makeTracker();
$this->addEntry($trackerId, '1.4');
$this->addEntry($trackerId, '1.2');
$this->migration()->up();
$this->assertSame(3, $this->countFor($trackerId));
}
public function test_backfill_clamps_negative_totals_to_zero(): void
{
$this->revertToLedger();
$trackerId = $this->makeTracker();
$this->addEntry($trackerId, '5');
$this->addEntry($trackerId, '-9');
$this->migration()->up();
$this->assertSame(0, $this->countFor($trackerId));
}
public function test_tracker_without_entries_defaults_to_zero(): void
{
$this->revertToLedger();
$trackerId = $this->makeTracker();
$this->migration()->up();
$this->assertSame(0, $this->countFor($trackerId));
}
public function test_up_drops_the_entries_table(): void
{
$this->revertToLedger();
$this->assertTrue(Schema::hasTable('entries'));
$this->migration()->up();
$this->assertFalse(Schema::hasTable('entries'));
$this->assertTrue(Schema::hasColumn('trackers', 'count'));
}
// The two down() tests start from the migrated schema, so they do not call revertToLedger().
public function test_down_recreates_entries_from_count(): void
{
$trackerId = $this->makeTracker();
DB::table('trackers')->where('id', $trackerId)->update(['count' => 7]);
$this->migration()->down();
$this->assertTrue(Schema::hasTable('entries'));
$this->assertFalse(Schema::hasColumn('trackers', 'count'));
$entries = DB::table('entries')->where('tracker_id', $trackerId)->get();
$this->assertCount(1, $entries);
$this->assertSame(7.0, (float) $entries->first()->quantity);
}
public function test_down_writes_no_entry_for_a_zero_count(): void
{
$trackerId = $this->makeTracker();
$this->migration()->down();
$this->assertSame(0, DB::table('entries')->where('tracker_id', $trackerId)->count());
}
}

View file

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Tracker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class CounterTest extends TestCase
{
use RefreshDatabase;
private function tracker(int $count = 0): Tracker
{
return Tracker::factory()->create(['count' => $count]);
}
public function test_increment_adds_exactly_one(): void
{
$tracker = $this->tracker(5);
$response = $this->postJson('/increment');
$response->assertOk()->assertJson(['count' => 6]);
$this->assertSame(6, $tracker->refresh()->count);
}
public function test_increments_accumulate(): void
{
$tracker = $this->tracker();
$this->postJson('/increment');
$this->postJson('/increment');
$this->postJson('/increment');
$this->assertSame(3, $tracker->refresh()->count);
}
public function test_increment_returns_404_without_a_tracker(): void
{
$this->postJson('/increment')->assertNotFound();
}
public function test_count_can_be_set_to_an_absolute_value(): void
{
$tracker = $this->tracker(3);
$response = $this->patchJson('/count', ['count' => 250]);
$response->assertOk()->assertJson(['count' => 250]);
$this->assertSame(250, $tracker->refresh()->count);
}
public function test_count_can_be_set_to_zero(): void
{
$tracker = $this->tracker(42);
$this->patchJson('/count', ['count' => 0])->assertOk();
$this->assertSame(0, $tracker->refresh()->count);
}
/**
* @return array<string, array{mixed}>
*/
public static function invalidCounts(): array
{
return [
'negative' => [-1],
'non-numeric string' => ['abc'],
'fractional' => [1.5],
'above unsigned int ceiling' => [4294967296],
];
}
#[DataProvider('invalidCounts')]
public function test_count_rejects_invalid_values(mixed $value): void
{
$tracker = $this->tracker(7);
$this->patchJson('/count', ['count' => $value])
->assertStatus(422)
->assertJsonValidationErrors('count');
$this->assertSame(7, $tracker->refresh()->count);
}
public function test_count_requires_a_value(): void
{
$this->tracker();
$this->patchJson('/count', [])
->assertStatus(422)
->assertJsonValidationErrors('count');
}
public function test_count_returns_404_without_a_tracker(): void
{
$this->patchJson('/count', ['count' => 5])->assertNotFound();
}
public function test_counter_endpoints_return_json_not_redirects(): void
{
$this->tracker();
$this->postJson('/increment')
->assertOk()
->assertHeader('content-type', 'application/json');
$this->patchJson('/count', ['count' => 1])
->assertOk()
->assertHeader('content-type', 'application/json');
}
public function test_count_is_cast_to_an_integer(): void
{
$this->tracker(12);
$this->assertSame(12, Tracker::first()->count);
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Tracker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TrackerTest extends TestCase
{
use RefreshDatabase;
public function test_show_reports_no_tracker_on_a_fresh_install(): void
{
$this->getJson('/tracker')
->assertOk()
->assertJson(['exists' => false]);
}
public function test_show_returns_the_tracker_once_created(): void
{
Tracker::factory()->create(['count' => 9]);
$this->getJson('/tracker')
->assertOk()
->assertJson([
'exists' => true,
'tracker' => ['count' => 9],
]);
}
public function test_a_tracker_can_be_created_without_a_label_or_unit(): void
{
$this->postJson('/tracker', [])->assertCreated();
$tracker = Tracker::first();
$this->assertSame('Counter', $tracker->label);
$this->assertSame('units', $tracker->unit);
$this->assertSame(0, $tracker->count);
}
public function test_a_new_counter_starts_at_zero(): void
{
$this->postJson('/tracker', []);
$this->getJson('/tracker')
->assertOk()
->assertJson(['tracker' => ['count' => 0]]);
}
public function test_creating_a_second_tracker_conflicts(): void
{
$this->postJson('/tracker', [])->assertCreated();
$this->postJson('/tracker', [])->assertStatus(409);
$this->assertSame(1, Tracker::count());
}
}

0
tests/Unit/.gitkeep Normal file
View file

View file

@ -1,16 +0,0 @@
<?php
namespace Tests\Unit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use RefreshDatabase;
public function test_that_true_is_true()
{
$this->assertTrue(true);
}
}