release/0.4.0 #60
21 changed files with 49 additions and 795 deletions
|
|
@ -1,11 +1,8 @@
|
||||||
APP_ENV=testing
|
APP_ENV=testing
|
||||||
APP_KEY=base64:+7T2RuonhTIij1yLp3rTOv2uQlYJh0TQulu20MlCA+s=
|
APP_KEY=base64:+7T2RuonhTIij1yLp3rTOv2uQlYJh0TQulu20MlCA+s=
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=sqlite
|
||||||
DB_HOST=db
|
DB_DATABASE=:memory:
|
||||||
DB_DATABASE=testing
|
|
||||||
DB_USERNAME=incr_user
|
|
||||||
DB_PASSWORD=incr_password
|
|
||||||
|
|
||||||
SESSION_DRIVER=array
|
SESSION_DRIVER=array
|
||||||
CACHE_STORE=array
|
CACHE_STORE=array
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,11 @@ jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: forge.lvl0.xyz/lvl0/incr-ci:php8.3-2
|
image: forge.lvl0.xyz/lvl0/incr-ci:php8.3-3
|
||||||
|
|
||||||
services:
|
|
||||||
db:
|
|
||||||
image: mysql:8.0
|
|
||||||
env:
|
|
||||||
MYSQL_DATABASE: testing
|
|
||||||
MYSQL_USER: incr_user
|
|
||||||
MYSQL_PASSWORD: incr_password
|
|
||||||
MYSQL_ROOT_PASSWORD: root_password
|
|
||||||
|
|
||||||
|
# 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:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -37,22 +31,6 @@ jobs:
|
||||||
- name: Prepare environment
|
- name: Prepare environment
|
||||||
run: cp .env.testing .env
|
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
|
- name: Lint
|
||||||
run: vendor/bin/pint --test
|
run: vendor/bin/pint --test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ jobs:
|
||||||
include:
|
include:
|
||||||
- name: incr-ci
|
- name: incr-ci
|
||||||
file: docker/build/Dockerfile.ci
|
file: docker/build/Dockerfile.ci
|
||||||
version: php8.3-2
|
version: php8.3-3
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -8,13 +8,16 @@
|
||||||
# Debian-based rather than alpine: ffr hit repeated DNS resolution timeouts
|
# Debian-based rather than alpine: ffr hit repeated DNS resolution timeouts
|
||||||
# against codeload.github.com on the alpine build.
|
# against codeload.github.com on the alpine build.
|
||||||
#
|
#
|
||||||
# pdo_mysql, not sqlite: the migration tests query information_schema and
|
# pdo_sqlite: tests run against sqlite in memory (see .env.testing). Service
|
||||||
# exercise MySQL-specific DDL, so CI runs against a real mysql:8.0 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
|
FROM php:8.3-cli
|
||||||
|
|
||||||
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
|
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
|
||||||
|
|
||||||
RUN install-php-extensions \
|
RUN install-php-extensions \
|
||||||
|
pdo_sqlite \
|
||||||
pdo_mysql \
|
pdo_mysql \
|
||||||
mbstring \
|
mbstring \
|
||||||
dom \
|
dom \
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,3 @@ parameters:
|
||||||
excludePaths:
|
excludePaths:
|
||||||
- bootstrap/*.php
|
- bootstrap/*.php
|
||||||
- storage/*
|
- 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
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||||
<env name="CACHE_STORE" value="array" 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="MAIL_MAILER" value="array" force="true"/>
|
||||||
<env name="PULSE_ENABLED" value="false" force="true"/>
|
<env name="PULSE_ENABLED" value="false" force="true"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||||
|
|
|
||||||
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue