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
21 changed files with 49 additions and 795 deletions
Showing only changes of commit 68ed294b82 - Show all commits

View file

@ -1,11 +1,8 @@
APP_ENV=testing
APP_KEY=base64:+7T2RuonhTIij1yLp3rTOv2uQlYJh0TQulu20MlCA+s=
DB_CONNECTION=mysql
DB_HOST=db
DB_DATABASE=testing
DB_USERNAME=incr_user
DB_PASSWORD=incr_password
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
SESSION_DRIVER=array
CACHE_STORE=array

View file

@ -10,17 +10,11 @@ jobs:
ci:
runs-on: docker
container:
image: forge.lvl0.xyz/lvl0/incr-ci:php8.3-2
services:
db:
image: mysql:8.0
env:
MYSQL_DATABASE: testing
MYSQL_USER: incr_user
MYSQL_PASSWORD: incr_password
MYSQL_ROOT_PASSWORD: root_password
image: forge.lvl0.xyz/lvl0/incr-ci:php8.3-3
# No service container: on this runner a job container cannot reach one
# (the service starts fine but lands on a different network). Tests run
# against sqlite in memory instead — see .env.testing.
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
@ -37,22 +31,6 @@ jobs:
- name: Prepare environment
run: cp .env.testing .env
- name: Wait for MySQL
run: |
for i in $(seq 1 30); do
if php -r 'exit(@fsockopen("db", 3306) ? 0 : 1);'; then
echo "MySQL is up after ${i} attempt(s)"
exit 0
fi
echo "Waiting for MySQL... ($i/30)"
sleep 2
done
echo "MySQL never became reachable on db:3306" >&2
exit 1
- name: Run migrations
run: php artisan migrate --force
- name: Lint
run: vendor/bin/pint --test

View file

@ -20,7 +20,7 @@ jobs:
include:
- name: incr-ci
file: docker/build/Dockerfile.ci
version: php8.3-2
version: php8.3-3
steps:
- uses: https://data.forgejo.org/actions/checkout@v4

View file

@ -1,25 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('assets', function (Blueprint $table) {
$table->id();
$table->string('symbol')->unique();
$table->string('full_name')->nullable();
$table->timestamps();
$table->index('symbol');
});
}
public function down(): void
{
Schema::dropIfExists('assets');
}
};

View file

@ -1,53 +0,0 @@
<?php
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('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->foreignId('asset_id')->nullable()->constrained()->onDelete('set null');
$table->boolean('price_tracking_enabled')->default(false);
$table->rememberToken();
$table->timestamps();
$table->index('asset_id');
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View file

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('asset_prices', function (Blueprint $table) {
$table->id();
$table->foreignId('asset_id')->constrained()->onDelete('cascade');
$table->date('date');
$table->decimal('price', 10, 4);
$table->timestamps();
$table->unique(['asset_id', 'date']);
$table->index('asset_id');
$table->index('date');
});
}
public function down(): void
{
Schema::dropIfExists('asset_prices');
}
};

View file

@ -1,29 +0,0 @@
<?php
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('milestones', function (Blueprint $table) {
$table->id();
$table->integer('target');
$table->string('description');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('milestones');
}
};

View file

@ -1,50 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('trackers', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('asset_id')->nullable()->constrained()->nullOnDelete();
$table->string('label');
$table->string('unit');
$table->boolean('price_tracking_enabled')->default(false);
$table->timestamps();
});
// Migrate existing users: create one tracker per user from their current asset_id + price_tracking_enabled
DB::table('users')->orderBy('id')->each(function (object $user) {
DB::table('trackers')->insert([
'user_id' => $user->id,
'asset_id' => $user->asset_id,
'label' => 'Portfolio',
'unit' => 'shares',
'price_tracking_enabled' => $user->price_tracking_enabled ?? false,
'created_at' => now(),
'updated_at' => now(),
]);
});
}
public function down(): void
{
// Restore asset_id and price_tracking_enabled back onto users before dropping trackers
DB::table('trackers')->orderBy('id')->each(function (object $tracker) {
DB::table('users')
->where('id', $tracker->user_id)
->update([
'asset_id' => $tracker->asset_id,
'price_tracking_enabled' => $tracker->price_tracking_enabled,
]);
});
Schema::dropIfExists('trackers');
}
};

View file

@ -1,30 +0,0 @@
<?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
{
public function up(): void
{
Schema::create('entries', function (Blueprint $table): void {
$table->id();
$table->foreignId('tracker_id')->constrained()->cascadeOnDelete();
$table->date('date');
$table->decimal('quantity', 12, 6);
$table->decimal('unit_price', 12, 4)->nullable();
$table->decimal('total_cost', 12, 2)->nullable();
$table->timestamps();
$table->index(['tracker_id', 'date']);
});
}
public function down(): void
{
Schema::dropIfExists('entries');
}
};

View file

@ -1,44 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('milestones', function (Blueprint $table) {
$table->foreignId('tracker_id')->nullable()->after('id')->constrained()->cascadeOnDelete();
});
// Backfill tracker_id on milestones
$trackerId = DB::table('trackers')->value('id');
if ($trackerId) {
DB::table('milestones')->update(['tracker_id' => $trackerId]);
}
Schema::table('milestones', function (Blueprint $table) {
$table->unsignedBigInteger('tracker_id')->nullable(false)->change();
});
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['asset_id']);
$table->dropColumn(['asset_id', 'price_tracking_enabled']);
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('asset_id')->nullable()->constrained()->nullOnDelete();
$table->boolean('price_tracking_enabled')->default(false);
});
Schema::table('milestones', function (Blueprint $table) {
$table->dropForeign(['tracker_id']);
$table->dropColumn('tracker_id');
});
}
};

View file

@ -1,26 +0,0 @@
<?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
{
public function up(): void
{
Schema::dropIfExists('milestones');
}
public function down(): void
{
Schema::create('milestones', function (Blueprint $table) {
$table->id();
$table->foreignId('tracker_id')->constrained()->cascadeOnDelete();
$table->integer('target');
$table->string('description');
$table->timestamps();
});
}
};

View file

@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('trackers', function (Blueprint $table): void {
$table->unsignedInteger('count')->default(0)->after('unit');
});
if (Schema::hasTable('entries')) {
DB::table('trackers')->orderBy('id')->each(function (object $tracker): void {
$total = (float) DB::table('entries')->where('tracker_id', $tracker->id)->sum('quantity');
DB::table('trackers')
->where('id', $tracker->id)
->update(['count' => max(0, (int) round($total))]);
});
}
Schema::dropIfExists('entries');
}
// Lossy by design: per-entry dates and prices cannot be rebuilt from a scalar.
public function down(): void
{
Schema::create('entries', function (Blueprint $table): void {
$table->id();
$table->foreignId('tracker_id')->constrained()->cascadeOnDelete();
$table->date('date');
$table->decimal('quantity', 12, 6);
$table->decimal('unit_price', 12, 4)->nullable();
$table->decimal('total_cost', 12, 2)->nullable();
$table->timestamps();
$table->index(['tracker_id', 'date']);
});
DB::table('trackers')->where('count', '>', 0)->orderBy('id')->each(function (object $tracker): void {
DB::table('entries')->insert([
'tracker_id' => $tracker->id,
'date' => now()->toDateString(),
'quantity' => $tracker->count,
'created_at' => now(),
'updated_at' => now(),
]);
});
Schema::table('trackers', function (Blueprint $table): void {
$table->dropColumn('count');
});
}
};

View file

@ -1,51 +0,0 @@
<?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
{
public function up(): void
{
Schema::table('trackers', function (Blueprint $table): void {
$table->dropForeign(['asset_id']);
$table->dropColumn(['asset_id', 'price_tracking_enabled']);
});
Schema::dropIfExists('asset_prices');
Schema::dropIfExists('assets');
}
// Lossy by design: recorded symbols and prices cannot be recovered.
public function down(): void
{
Schema::create('assets', function (Blueprint $table): void {
$table->id();
$table->string('symbol')->unique();
$table->string('full_name')->nullable();
$table->timestamps();
$table->index('symbol');
});
Schema::create('asset_prices', function (Blueprint $table): void {
$table->id();
$table->foreignId('asset_id')->constrained()->onDelete('cascade');
$table->date('date');
$table->decimal('price', 10, 4);
$table->timestamps();
$table->unique(['asset_id', 'date']);
$table->index('asset_id');
$table->index('date');
});
Schema::table('trackers', function (Blueprint $table): void {
$table->foreignId('asset_id')->nullable()->constrained()->nullOnDelete();
$table->boolean('price_tracking_enabled')->default(false);
});
}
};

View file

@ -1,57 +0,0 @@
<?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
{
public function up(): void
{
Schema::table('trackers', function (Blueprint $table): void {
$table->dropForeign(['user_id']);
$table->dropColumn('user_id');
});
Schema::dropIfExists('sessions');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('users');
}
// Lossy by design: the app has no users, so nothing is restored into these tables.
public function down(): void
{
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table): void {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table): void {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
// Nullable, unlike the original: existing trackers have no user to point at,
// so a NOT NULL foreign key cannot be added back.
Schema::table('trackers', function (Blueprint $table): void {
$table->foreignId('user_id')->nullable()->after('id')->constrained()->cascadeOnDelete();
});
}
};

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Consolidates the thirteen migrations that preceded v0.4.0. Those created and
* then dropped assets, users, sessions, milestones and entries to arrive at a
* single table; replaying that chain required MySQL-specific DDL. Deployments
* migrated before this have the old entries in their migrations table and skip
* it via the guard below.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('trackers')) {
return;
}
Schema::create('trackers', function (Blueprint $table): void {
$table->id();
$table->string('label');
$table->string('unit');
$table->unsignedInteger('count')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('trackers');
}
};

View file

@ -8,13 +8,16 @@
# Debian-based rather than alpine: ffr hit repeated DNS resolution timeouts
# against codeload.github.com on the alpine build.
#
# pdo_mysql, not sqlite: the migration tests query information_schema and
# exercise MySQL-specific DDL, so CI runs against a real mysql:8.0 service.
# pdo_sqlite: tests run against sqlite in memory (see .env.testing). Service
# containers are not reachable from job containers on this runner, so CI must
# not depend on one. pdo_mysql is kept so artisan can talk to a real database
# when run manually inside this image.
FROM php:8.3-cli
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions \
pdo_sqlite \
pdo_mysql \
mbstring \
dom \

View file

@ -11,11 +11,3 @@ parameters:
excludePaths:
- bootstrap/*.php
- storage/*
ignoreErrors:
# Migration files return an anonymous class; the base Migration declares neither up() nor down().
# An interface on the migration is not an option: it would live under autoload-dev, which
# production omits (`composer install --no-dev`), so every migration would fatal on deploy.
-
message: '#Call to an undefined method Illuminate\\Database\\Migrations\\Migration::(up|down)\(\)#'
path: tests/Feature/*MigrationTest.php

View file

@ -22,7 +22,6 @@
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
<env name="CACHE_STORE" value="array" force="true"/>
<env name="DB_DATABASE" value="testing" force="true"/>
<env name="MAIL_MAILER" value="array" force="true"/>
<env name="PULSE_ENABLED" value="false" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>

View file

@ -1,145 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Database\Migrations\Migration;
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(): Migration
{
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
{
return DB::table('trackers')->insertGetId([
'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

@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class DropAssetsMigrationTest extends TestCase
{
use RefreshDatabase;
// Each require returns a fresh anonymous-class instance; there is no name to collide.
private function migration(): Migration
{
return require __DIR__.'/../../database/migrations/2026_08_15_000003_drop_assets_and_pricing.php';
}
public function test_the_migrated_schema_has_no_asset_or_price_columns(): void
{
$this->assertFalse(Schema::hasTable('assets'));
$this->assertFalse(Schema::hasTable('asset_prices'));
$this->assertFalse(Schema::hasColumn('trackers', 'asset_id'));
$this->assertFalse(Schema::hasColumn('trackers', 'price_tracking_enabled'));
}
public function test_down_restores_the_tables_and_columns(): void
{
$this->migration()->down();
$this->assertTrue(Schema::hasTable('assets'));
$this->assertTrue(Schema::hasTable('asset_prices'));
$this->assertTrue(Schema::hasColumn('trackers', 'asset_id'));
$this->assertTrue(Schema::hasColumn('trackers', 'price_tracking_enabled'));
}
public function test_up_drops_them_again_respecting_foreign_keys(): void
{
$this->migration()->down();
$this->migration()->up();
$this->assertFalse(Schema::hasTable('assets'));
$this->assertFalse(Schema::hasTable('asset_prices'));
$this->assertFalse(Schema::hasColumn('trackers', 'asset_id'));
$this->assertFalse(Schema::hasColumn('trackers', 'price_tracking_enabled'));
}
public function test_dropping_assets_does_not_disturb_the_counter(): void
{
$this->migration()->down();
$trackerId = DB::table('trackers')->insertGetId([
'label' => 'Counter',
'unit' => 'units',
'count' => 31,
'created_at' => now(),
'updated_at' => now(),
]);
$this->migration()->up();
$this->assertSame(31, (int) DB::table('trackers')->where('id', $trackerId)->value('count'));
}
}

View file

@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class DropUsersMigrationTest extends TestCase
{
use RefreshDatabase;
// Each require returns a fresh anonymous-class instance; there is no name to collide.
private function migration(): Migration
{
return require __DIR__.'/../../database/migrations/2026_08_15_000004_drop_users_and_sessions.php';
}
public function test_the_migrated_schema_has_no_user_tables(): void
{
$this->assertFalse(Schema::hasTable('users'));
$this->assertFalse(Schema::hasTable('sessions'));
$this->assertFalse(Schema::hasTable('password_reset_tokens'));
$this->assertFalse(Schema::hasColumn('trackers', 'user_id'));
}
public function test_down_restores_the_tables_and_column(): void
{
$this->migration()->down();
$this->assertTrue(Schema::hasTable('users'));
$this->assertTrue(Schema::hasTable('sessions'));
$this->assertTrue(Schema::hasTable('password_reset_tokens'));
$this->assertTrue(Schema::hasColumn('trackers', 'user_id'));
}
public function test_restored_user_id_is_nullable_so_existing_trackers_survive(): void
{
DB::table('trackers')->insert([
'label' => 'Counter',
'unit' => 'units',
'count' => 3,
'created_at' => now(),
'updated_at' => now(),
]);
$this->migration()->down();
$nullable = DB::selectOne(
'select is_nullable from information_schema.columns
where table_schema = database() and table_name = ? and column_name = ?',
['trackers', 'user_id']
);
$this->assertSame('YES', $nullable->is_nullable ?? $nullable->IS_NULLABLE);
}
public function test_up_drops_them_again_respecting_foreign_keys(): void
{
$this->migration()->down();
$this->migration()->up();
$this->assertFalse(Schema::hasTable('users'));
$this->assertFalse(Schema::hasColumn('trackers', 'user_id'));
}
public function test_the_counter_survives_the_round_trip(): void
{
$trackerId = DB::table('trackers')->insertGetId([
'label' => 'Counter',
'unit' => 'units',
'count' => 77,
'created_at' => now(),
'updated_at' => now(),
]);
$this->migration()->down();
$this->migration()->up();
$this->assertSame(77, (int) DB::table('trackers')->where('id', $trackerId)->value('count'));
}
}