diff --git a/app/Models/Tracker.php b/app/Models/Tracker.php index d37ed45..3fbdb2d 100644 --- a/app/Models/Tracker.php +++ b/app/Models/Tracker.php @@ -4,11 +4,16 @@ namespace App\Models; +use Database\Factories\TrackerFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Tracker extends Model { + /** @use HasFactory */ + use HasFactory; + protected $fillable = [ 'user_id', 'asset_id', diff --git a/database/factories/TrackerFactory.php b/database/factories/TrackerFactory.php new file mode 100644 index 0000000..4906e01 --- /dev/null +++ b/database/factories/TrackerFactory.php @@ -0,0 +1,30 @@ + + */ +class TrackerFactory extends Factory +{ + protected $model = Tracker::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => fn () => User::default()->id, + 'label' => 'Counter', + 'unit' => 'units', + 'count' => 0, + ]; + } +} diff --git a/tests/Feature/CountBackfillMigrationTest.php b/tests/Feature/CountBackfillMigrationTest.php new file mode 100644 index 0000000..d0d5ab5 --- /dev/null +++ b/tests/Feature/CountBackfillMigrationTest.php @@ -0,0 +1,153 @@ +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()); + } +} diff --git a/tests/Feature/CounterTest.php b/tests/Feature/CounterTest.php new file mode 100644 index 0000000..36037d4 --- /dev/null +++ b/tests/Feature/CounterTest.php @@ -0,0 +1,124 @@ +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 + */ + 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); + } +} diff --git a/tests/Feature/TrackerTest.php b/tests/Feature/TrackerTest.php new file mode 100644 index 0000000..5a0673c --- /dev/null +++ b/tests/Feature/TrackerTest.php @@ -0,0 +1,61 @@ +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()); + } +} diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 2cc6866..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -}