From 79e8106c8d41c89c21150979646ea871a7491eea Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Tue, 24 Feb 2026 14:35:54 +0100 Subject: [PATCH 01/14] Add command to import hop varieties --- .gitignore | 1 + app/Actions/MapHopData.php | 52 +++++ app/Actions/UpsertHop.php | 108 ++++++++++ app/Console/Commands/HopsImportCommand.php | 40 ++++ app/Helpers/Json5Parser.php | 17 ++ app/Jobs/ImportHopVarietyJob.php | 58 ++++++ .../{Range.php => RangeOrNumber.php} | 2 +- eslint.config.js | 3 + storage/framework/testing/.gitignore | 2 - .../Commands/HopsImportCommandTest.php | 62 ++++++ .../Feature/Jobs/ImportHopVarietyJobTest.php | 187 ++++++++++++++++++ .../{RangeTest.php => RangeOrNumberTest.php} | 12 +- 12 files changed, 535 insertions(+), 9 deletions(-) create mode 100644 app/Actions/MapHopData.php create mode 100644 app/Actions/UpsertHop.php create mode 100644 app/Console/Commands/HopsImportCommand.php create mode 100644 app/Helpers/Json5Parser.php create mode 100644 app/Jobs/ImportHopVarietyJob.php rename app/ValueObjects/{Range.php => RangeOrNumber.php} (98%) delete mode 100644 storage/framework/testing/.gitignore create mode 100644 tests/Feature/Console/Commands/HopsImportCommandTest.php create mode 100644 tests/Feature/Jobs/ImportHopVarietyJobTest.php rename tests/Feature/{RangeTest.php => RangeOrNumberTest.php} (73%) diff --git a/.gitignore b/.gitignore index 07a39ea..e6b9666 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ supervisord.pid .php-cs-fixer.cache phpunit.xml /database/sqlite +/storage/framework/testing diff --git a/app/Actions/MapHopData.php b/app/Actions/MapHopData.php new file mode 100644 index 0000000..f14ecf9 --- /dev/null +++ b/app/Actions/MapHopData.php @@ -0,0 +1,52 @@ + $data["name"] ?? null, + "country" => $data["country"] ?? null, + "description" => $data["origin"] ?? null, + "alpha_acid_min" => $data["ingredients"]["alphas"]["min"] ?? null, + "alpha_acid_max" => $data["ingredients"]["alphas"]["max"] ?? null, + "beta_acid_min" => $data["ingredients"]["betas"]["min"] ?? null, + "beta_acid_max" => $data["ingredients"]["betas"]["max"] ?? null, + "cohumulone_min" => $data["ingredients"]["cohumulones"]["min"] ?? null, + "cohumulone_max" => $data["ingredients"]["cohumulones"]["max"] ?? null, + "total_oil_min" => $data["ingredients"]["oils"]["min"] ?? null, + "total_oil_max" => $data["ingredients"]["oils"]["max"] ?? null, + "polyphenol_min" => $data["ingredients"]["polyphenols"]["min"] ?? null, + "polyphenol_max" => $data["ingredients"]["polyphenols"]["max"] ?? null, + "xanthohumol_min" => $data["ingredients"]["xanthohumols"]["min"] ?? null, + "xanthohumol_max" => $data["ingredients"]["xanthohumols"]["max"] ?? null, + "farnesene_min" => $data["ingredients"]["farnesenes"]["min"] ?? null, + "farnesene_max" => $data["ingredients"]["farnesenes"]["max"] ?? null, + "linalool_min" => $data["ingredients"]["linalool"]["min"] ?? null, + "linalool_max" => $data["ingredients"]["linalool"]["max"] ?? null, + "aroma_citrusy" => $data["aroma"]["citrusy"] ?? null, + "aroma_fruity" => $data["aroma"]["fruity"] ?? null, + "aroma_floral" => $data["aroma"]["floral"] ?? null, + "aroma_herbal" => $data["aroma"]["herbal"] ?? null, + "aroma_spicy" => $data["aroma"]["spicy"] ?? null, + "aroma_resinous" => $data["aroma"]["resinous"] ?? null, + "aroma_sugarlike" => $data["aroma"]["sugarlike"] ?? null, + "aroma_miscellaneous" => $data["aroma"]["misc"] ?? null, + "aroma_descriptors" => $data["aromaDescription"] ?? [], + "substitutes" => $this->extractSubstitutes($data), + ]; + } + + private function extractSubstitutes(array $data): array + { + $alternatives = $data["ingredients"]["alternatives"] ?? []; + $brewhouse = $alternatives["brewhouse"] ?? []; + $dryhopping = $alternatives["dryhopping"] ?? []; + + return array_values(array_unique(array_merge($brewhouse, $dryhopping))); + } +} diff --git a/app/Actions/UpsertHop.php b/app/Actions/UpsertHop.php new file mode 100644 index 0000000..8e166dd --- /dev/null +++ b/app/Actions/UpsertHop.php @@ -0,0 +1,108 @@ +validate($data); + + return Hop::updateOrCreate( + ["name" => $validated["name"]], + $this->buildAttributes($validated), + ); + } + + private function validate(array $data): array + { + $validator = Validator::make($data, [ + "name" => ["required", "string", "max:255"], + "country" => ["nullable", "string", "max:255"], + "description" => ["nullable", "string"], + "alpha_acid_min" => ["nullable", "numeric", "min:0"], + "alpha_acid_max" => ["nullable", "numeric", "min:0"], + "beta_acid_min" => ["nullable", "numeric", "min:0"], + "beta_acid_max" => ["nullable", "numeric", "min:0"], + "cohumulone_min" => ["nullable", "numeric", "min:0"], + "cohumulone_max" => ["nullable", "numeric", "min:0"], + "total_oil_min" => ["nullable", "numeric", "min:0"], + "total_oil_max" => ["nullable", "numeric", "min:0"], + "polyphenol_min" => ["nullable", "numeric", "min:0"], + "polyphenol_max" => ["nullable", "numeric", "min:0"], + "xanthohumol_min" => ["nullable", "numeric", "min:0"], + "xanthohumol_max" => ["nullable", "numeric", "min:0"], + "farnesene_min" => ["nullable", "numeric", "min:0"], + "farnesene_max" => ["nullable", "numeric", "min:0"], + "linalool_min" => ["nullable", "numeric", "min:0"], + "linalool_max" => ["nullable", "numeric", "min:0"], + "aroma_citrusy" => ["nullable", "integer", "between:0,5"], + "aroma_fruity" => ["nullable", "integer", "between:0,5"], + "aroma_floral" => ["nullable", "integer", "between:0,5"], + "aroma_herbal" => ["nullable", "integer", "between:0,5"], + "aroma_spicy" => ["nullable", "integer", "between:0,5"], + "aroma_resinous" => ["nullable", "integer", "between:0,5"], + "aroma_sugarlike" => ["nullable", "integer", "between:0,5"], + "aroma_miscellaneous" => ["nullable", "integer", "between:0,5"], + "aroma_descriptors" => ["nullable", "array"], + "aroma_descriptors.*" => ["string"], + "substitutes" => ["nullable", "array"], + "substitutes.*" => ["string"], + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + return $validator->validated(); + } + + private function buildAttributes(array $validated): array + { + $attributes = [ + "slug" => Str::slug($validated["name"]) . "-hop", + "country" => $validated["country"], + "description" => $validated["description"], + "aroma_citrusy" => $validated["aroma_citrusy"], + "aroma_fruity" => $validated["aroma_fruity"], + "aroma_floral" => $validated["aroma_floral"], + "aroma_herbal" => $validated["aroma_herbal"], + "aroma_spicy" => $validated["aroma_spicy"], + "aroma_resinous" => $validated["aroma_resinous"], + "aroma_sugarlike" => $validated["aroma_sugarlike"], + "aroma_miscellaneous" => $validated["aroma_miscellaneous"], + "aroma_descriptors" => $validated["aroma_descriptors"] ?? [], + "substitutes" => $validated["substitutes"] ?? [], + ]; + + $rangeFields = [ + "alpha_acid", + "beta_acid", + "cohumulone", + "total_oil", + "polyphenol", + "xanthohumol", + "farnesene", + "linalool", + ]; + + foreach ($rangeFields as $field) { + $min = $validated["{$field}_min"] ?? null; + $max = $validated["{$field}_max"] ?? null; + + $attributes[$field] = ($min !== null && $max !== null) + ? RangeOrNumber::fromRange((float)$min, (float)$max) + : null; + } + + return $attributes; + } +} diff --git a/app/Console/Commands/HopsImportCommand.php b/app/Console/Commands/HopsImportCommand.php new file mode 100644 index 0000000..e953a94 --- /dev/null +++ b/app/Console/Commands/HopsImportCommand.php @@ -0,0 +1,40 @@ +files("hops_data")) + ->filter(fn(string $file): bool => in_array( + pathinfo($file, PATHINFO_EXTENSION), + ["json", "json5"], + true, + )); + + if ($files->isEmpty()) { + $this->info("No files found in hops_data directory."); + + return; + } + + $this->info("Found {$files->count()} file(s) to import."); + + foreach ($files as $file) { + $this->info("Dispatching import for: {$file}"); + ImportHopVarietyJob::dispatch($file); + } + + $this->info("All import jobs have been dispatched."); + } +} diff --git a/app/Helpers/Json5Parser.php b/app/Helpers/Json5Parser.php new file mode 100644 index 0000000..4b12b8e --- /dev/null +++ b/app/Helpers/Json5Parser.php @@ -0,0 +1,17 @@ +get($this->filePath); + + if ($content === null) { + Log::warning("ImportHopVarietyJob: File not found: {$this->filePath}"); + + return; + } + + $data = $parser->parse($content); + + if ($data === null) { + Log::warning("ImportHopVarietyJob: Failed to parse JSON5: {$this->filePath}"); + + return; + } + + $mapped = $mapper->execute($data); + + try { + $upsert->execute($mapped); + } catch (ValidationException $e) { + Log::warning("ImportHopVarietyJob: Validation failed for {$this->filePath}", [ + "errors" => $e->errors(), + ]); + } + } +} diff --git a/app/ValueObjects/Range.php b/app/ValueObjects/RangeOrNumber.php similarity index 98% rename from app/ValueObjects/Range.php rename to app/ValueObjects/RangeOrNumber.php index c5f58dd..4840700 100644 --- a/app/ValueObjects/Range.php +++ b/app/ValueObjects/RangeOrNumber.php @@ -6,7 +6,7 @@ use InvalidArgumentException; -class Range +class RangeOrNumber { public function __construct( public readonly ?float $min, diff --git a/eslint.config.js b/eslint.config.js index a71a506..489fed9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,4 +2,7 @@ import blumilkDefault from '@blumilksoftware/eslint-config' export default [ ...blumilkDefault, + { + ignores: ['storage/**'], + }, ] diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/framework/testing/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/Feature/Console/Commands/HopsImportCommandTest.php b/tests/Feature/Console/Commands/HopsImportCommandTest.php new file mode 100644 index 0000000..d8b019a --- /dev/null +++ b/tests/Feature/Console/Commands/HopsImportCommandTest.php @@ -0,0 +1,62 @@ +put("hops_data/citra.json5", "{}"); + Storage::disk("local")->put("hops_data/mosaic.json5", "{}"); + Storage::disk("local")->put("hops_data/not-a-hop.txt", "test"); + + $this->artisan("hops:import") + ->expectsOutput("Found 2 file(s) to import.") + ->expectsOutput("Dispatching import for: hops_data/citra.json5") + ->expectsOutput("Dispatching import for: hops_data/mosaic.json5") + ->expectsOutput("All import jobs have been dispatched.") + ->assertExitCode(0); + + Bus::assertDispatched(ImportHopVarietyJob::class, fn($job) => $job->filePath === "hops_data/citra.json5"); + + Bus::assertDispatched(ImportHopVarietyJob::class, fn($job) => $job->filePath === "hops_data/mosaic.json5"); + + Bus::assertNotDispatched(ImportHopVarietyJob::class, fn($job) => $job->filePath === "hops_data/not-a-hop.txt"); + } + + public function testItAlsoDispatchesForPlainJsonFiles(): void + { + Storage::fake("local"); + Bus::fake(); + + Storage::disk("local")->put("hops_data/citra.json", "{}"); + + $this->artisan("hops:import") + ->expectsOutput("Found 1 file(s) to import.") + ->assertExitCode(0); + + Bus::assertDispatched(ImportHopVarietyJob::class, 1); + } + + public function testItShowsMessageWhenNoFilesFound(): void + { + Storage::fake("local"); + + $this->artisan("hops:import") + ->expectsOutput("No files found in hops_data directory.") + ->assertExitCode(0); + } +} diff --git a/tests/Feature/Jobs/ImportHopVarietyJobTest.php b/tests/Feature/Jobs/ImportHopVarietyJobTest.php new file mode 100644 index 0000000..bb992c8 --- /dev/null +++ b/tests/Feature/Jobs/ImportHopVarietyJobTest.php @@ -0,0 +1,187 @@ +put("hops_data/cascade.json5", $this->sampleJson5()); + + ImportHopVarietyJob::dispatchSync("hops_data/cascade.json5"); + + $this->assertDatabaseHas("hops", [ + "name" => "Cascade", + "slug" => "cascade-hop", + "country" => "US", + ]); + + $hop = Hop::where("name", "Cascade")->first(); + $this->assertNotNull($hop); + $this->assertEquals("A very popular aroma hop.", $hop->description); + $this->assertEquals(3, $hop->aroma_citrusy); + $this->assertEquals(3, $hop->aroma_fruity); + $this->assertEquals(1, $hop->aroma_floral); + $this->assertEquals(0, $hop->aroma_spicy); + $this->assertEquals(["lime", "black currant"], $hop->aroma_descriptors); + $this->assertEquals(["centennial", "lemondrop"], $hop->substitutes); + $this->assertNotNull($hop->alpha_acid); + $this->assertEquals(4.5, $hop->alpha_acid->min); + $this->assertEquals(7.0, $hop->alpha_acid->max); + $this->assertNull($hop->polyphenol); + } + + public function testItUpdatesExistingHopOnReimport(): void + { + Storage::fake("local"); + Storage::disk("local")->put("hops_data/cascade.json5", $this->sampleJson5()); + + ImportHopVarietyJob::dispatchSync("hops_data/cascade.json5"); + ImportHopVarietyJob::dispatchSync("hops_data/cascade.json5"); + + $this->assertDatabaseCount("hops", 1); + } + + public function testItLogsWarningForMissingFile(): void + { + Storage::fake("local"); + Log::shouldReceive("warning") + ->once() + ->withArgs(fn(string $msg) => str_contains($msg, "File not found")); + + ImportHopVarietyJob::dispatchSync("hops_data/nonexistent.json5"); + + $this->assertDatabaseCount("hops", 0); + } + + public function testItLogsWarningForInvalidJson(): void + { + Storage::fake("local"); + Storage::disk("local")->put("hops_data/bad.json5", "not valid json at all {{{{"); + + Log::shouldReceive("warning") + ->once() + ->withArgs(fn(string $msg) => str_contains($msg, "Failed to parse")); + + ImportHopVarietyJob::dispatchSync("hops_data/bad.json5"); + + $this->assertDatabaseCount("hops", 0); + } + + public function testItLogsWarningForValidationFailure(): void + { + Storage::fake("local"); + // Missing required 'name' field + Storage::disk("local")->put("hops_data/invalid.json5", '{ country: "US" }'); + + Log::shouldReceive("warning") + ->once() + ->withArgs(fn(string $msg) => str_contains($msg, "Validation failed")); + + ImportHopVarietyJob::dispatchSync("hops_data/invalid.json5"); + + $this->assertDatabaseCount("hops", 0); + } + + public function testItHandlesNullIngredientRanges(): void + { + Storage::fake("local"); + $json5 = <<<'JSON5' + { + name: "Test Hop", + country: "DE", + aroma: { + citrusy: 1, + fruity: 0, + floral: 0, + herbal: 0, + spicy: 0, + resinous: 0, + sugarlike: 0, + misc: 0 + }, + aromaDescription: [], + ingredients: { + alphas: { min: 5.0, max: 8.0 }, + betas: null, + cohumulones: null, + polyphenols: null, + xanthohumols: null, + oils: null, + farnesenes: null, + linalool: null, + alternatives: { + brewhouse: [], + dryhopping: [] + } + } + } + JSON5; + + Storage::disk("local")->put("hops_data/test.json5", $json5); + + ImportHopVarietyJob::dispatchSync("hops_data/test.json5"); + + $hop = Hop::where("name", "Test Hop")->first(); + $this->assertNotNull($hop); + $this->assertNotNull($hop->alpha_acid); + $this->assertNull($hop->beta_acid); + $this->assertNull($hop->polyphenol); + } + + private function sampleJson5(): string + { + return <<<'JSON5' + { + id: "cascade", + name: "Cascade", + altName: null, + country: "US", + descriptors: ["citrusy", "fruity", "herbal"], + origin: "A very popular aroma hop.", + aroma: { + citrusy: 3, + fruity: 3, + floral: 1, + herbal: 3, + spicy: 0, + resinous: 1, + sugarlike: 0, + misc: 0 + }, + aromaDescription: ["lime", "black currant"], + agronomic: { + yield: { min: 1600, max: 2200 }, + maturity: "early to mid early" + }, + ingredients: { + alphas: { min: 4.5, max: 7.0 }, + betas: { min: 4.5, max: 7.0 }, + cohumulones: { min: 33, max: 40 }, + polyphenols: null, + xanthohumols: { min: 0.1, max: 0.4 }, + oils: { min: 0.8, max: 1.5 }, + farnesenes: { min: 4.0, max: 8.0 }, + linalool: { min: 0.4, max: 0.6 }, + thiols: "high", + alternatives: { + brewhouse: ["centennial", "lemondrop"], + dryhopping: ["centennial", "lemondrop"] + } + } + } + JSON5; + } +} diff --git a/tests/Feature/RangeTest.php b/tests/Feature/RangeOrNumberTest.php similarity index 73% rename from tests/Feature/RangeTest.php rename to tests/Feature/RangeOrNumberTest.php index cbbb527..6eeb343 100644 --- a/tests/Feature/RangeTest.php +++ b/tests/Feature/RangeOrNumberTest.php @@ -4,15 +4,15 @@ namespace Tests\Feature; -use HopsWeb\ValueObjects\Range; +use HopsWeb\ValueObjects\RangeOrNumber; use InvalidArgumentException; use Tests\TestCase; -class RangeTest extends TestCase +class RangeOrNumberTest extends TestCase { public function testRangeCanBeCreatedWithMinAndMax(): void { - $range = Range::fromRange(1, 10); + $range = RangeOrNumber::fromRange(1, 10); $this->assertEquals(1, $range->min); $this->assertEquals(10, $range->max); @@ -20,7 +20,7 @@ public function testRangeCanBeCreatedWithMinAndMax(): void public function testRangeCanBeCreatedWithExactValue(): void { - $range = Range::fromNumber(1); + $range = RangeOrNumber::fromNumber(1); $this->assertEquals(1, $range->exact); } @@ -29,13 +29,13 @@ public function testRangeThrowsExceptionWhenMinIsGreaterThanMax(): void { $this->expectException(InvalidArgumentException::class); - Range::fromRange(10, 1); + RangeOrNumber::fromRange(10, 1); } public function testRangeThrowsExceptionWhenExactValueIsNegative(): void { $this->expectException(InvalidArgumentException::class); - Range::fromNumber(-1); + RangeOrNumber::fromNumber(-1); } } From 8f3f0fac900042da6f41f83e9d98ac5a56852997 Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Tue, 24 Feb 2026 14:40:09 +0100 Subject: [PATCH 02/14] Move test JSON5 files to fixtures --- .../Feature/Jobs/ImportHopVarietyJobTest.php | 86 ++----------------- tests/Fixtures/hops/cascade.json5 | 38 ++++++++ tests/Fixtures/hops/minimal.json5 | 29 +++++++ 3 files changed, 74 insertions(+), 79 deletions(-) create mode 100644 tests/Fixtures/hops/cascade.json5 create mode 100644 tests/Fixtures/hops/minimal.json5 diff --git a/tests/Feature/Jobs/ImportHopVarietyJobTest.php b/tests/Feature/Jobs/ImportHopVarietyJobTest.php index bb992c8..725a327 100644 --- a/tests/Feature/Jobs/ImportHopVarietyJobTest.php +++ b/tests/Feature/Jobs/ImportHopVarietyJobTest.php @@ -18,7 +18,7 @@ class ImportHopVarietyJobTest extends TestCase public function testItImportsHopFromJson5File(): void { Storage::fake("local"); - Storage::disk("local")->put("hops_data/cascade.json5", $this->sampleJson5()); + Storage::disk("local")->put("hops_data/cascade.json5", $this->fixture("cascade.json5")); ImportHopVarietyJob::dispatchSync("hops_data/cascade.json5"); @@ -46,7 +46,7 @@ public function testItImportsHopFromJson5File(): void public function testItUpdatesExistingHopOnReimport(): void { Storage::fake("local"); - Storage::disk("local")->put("hops_data/cascade.json5", $this->sampleJson5()); + Storage::disk("local")->put("hops_data/cascade.json5", $this->fixture("cascade.json5")); ImportHopVarietyJob::dispatchSync("hops_data/cascade.json5"); ImportHopVarietyJob::dispatchSync("hops_data/cascade.json5"); @@ -83,7 +83,6 @@ public function testItLogsWarningForInvalidJson(): void public function testItLogsWarningForValidationFailure(): void { Storage::fake("local"); - // Missing required 'name' field Storage::disk("local")->put("hops_data/invalid.json5", '{ country: "US" }'); Log::shouldReceive("warning") @@ -98,41 +97,9 @@ public function testItLogsWarningForValidationFailure(): void public function testItHandlesNullIngredientRanges(): void { Storage::fake("local"); - $json5 = <<<'JSON5' - { - name: "Test Hop", - country: "DE", - aroma: { - citrusy: 1, - fruity: 0, - floral: 0, - herbal: 0, - spicy: 0, - resinous: 0, - sugarlike: 0, - misc: 0 - }, - aromaDescription: [], - ingredients: { - alphas: { min: 5.0, max: 8.0 }, - betas: null, - cohumulones: null, - polyphenols: null, - xanthohumols: null, - oils: null, - farnesenes: null, - linalool: null, - alternatives: { - brewhouse: [], - dryhopping: [] - } - } - } - JSON5; - - Storage::disk("local")->put("hops_data/test.json5", $json5); - - ImportHopVarietyJob::dispatchSync("hops_data/test.json5"); + Storage::disk("local")->put("hops_data/minimal.json5", $this->fixture("minimal.json5")); + + ImportHopVarietyJob::dispatchSync("hops_data/minimal.json5"); $hop = Hop::where("name", "Test Hop")->first(); $this->assertNotNull($hop); @@ -141,47 +108,8 @@ public function testItHandlesNullIngredientRanges(): void $this->assertNull($hop->polyphenol); } - private function sampleJson5(): string + private function fixture(string $filename): string { - return <<<'JSON5' - { - id: "cascade", - name: "Cascade", - altName: null, - country: "US", - descriptors: ["citrusy", "fruity", "herbal"], - origin: "A very popular aroma hop.", - aroma: { - citrusy: 3, - fruity: 3, - floral: 1, - herbal: 3, - spicy: 0, - resinous: 1, - sugarlike: 0, - misc: 0 - }, - aromaDescription: ["lime", "black currant"], - agronomic: { - yield: { min: 1600, max: 2200 }, - maturity: "early to mid early" - }, - ingredients: { - alphas: { min: 4.5, max: 7.0 }, - betas: { min: 4.5, max: 7.0 }, - cohumulones: { min: 33, max: 40 }, - polyphenols: null, - xanthohumols: { min: 0.1, max: 0.4 }, - oils: { min: 0.8, max: 1.5 }, - farnesenes: { min: 4.0, max: 8.0 }, - linalool: { min: 0.4, max: 0.6 }, - thiols: "high", - alternatives: { - brewhouse: ["centennial", "lemondrop"], - dryhopping: ["centennial", "lemondrop"] - } - } - } - JSON5; + return file_get_contents(__DIR__ . "/../../Fixtures/hops/{$filename}"); } } diff --git a/tests/Fixtures/hops/cascade.json5 b/tests/Fixtures/hops/cascade.json5 new file mode 100644 index 0000000..4cd1c2e --- /dev/null +++ b/tests/Fixtures/hops/cascade.json5 @@ -0,0 +1,38 @@ +{ + id: "cascade", + name: "Cascade", + altName: null, + country: "US", + descriptors: ["citrusy", "fruity", "herbal"], + origin: "A very popular aroma hop.", + aroma: { + citrusy: 3, + fruity: 3, + floral: 1, + herbal: 3, + spicy: 0, + resinous: 1, + sugarlike: 0, + misc: 0 + }, + aromaDescription: ["lime", "black currant"], + agronomic: { + yield: { min: 1600, max: 2200 }, + maturity: "early to mid early" + }, + ingredients: { + alphas: { min: 4.5, max: 7.0 }, + betas: { min: 4.5, max: 7.0 }, + cohumulones: { min: 33, max: 40 }, + polyphenols: null, + xanthohumols: { min: 0.1, max: 0.4 }, + oils: { min: 0.8, max: 1.5 }, + farnesenes: { min: 4.0, max: 8.0 }, + linalool: { min: 0.4, max: 0.6 }, + thiols: "high", + alternatives: { + brewhouse: ["centennial", "lemondrop"], + dryhopping: ["centennial", "lemondrop"] + } + } +} diff --git a/tests/Fixtures/hops/minimal.json5 b/tests/Fixtures/hops/minimal.json5 new file mode 100644 index 0000000..5c760e7 --- /dev/null +++ b/tests/Fixtures/hops/minimal.json5 @@ -0,0 +1,29 @@ +{ + name: "Test Hop", + country: "DE", + aroma: { + citrusy: 1, + fruity: 0, + floral: 0, + herbal: 0, + spicy: 0, + resinous: 0, + sugarlike: 0, + misc: 0 + }, + aromaDescription: [], + ingredients: { + alphas: { min: 5.0, max: 8.0 }, + betas: null, + cohumulones: null, + polyphenols: null, + xanthohumols: null, + oils: null, + farnesenes: null, + linalool: null, + alternatives: { + brewhouse: [], + dryhopping: [] + } + } +} From 66f3739635e9a61ecc9e2885a301ee28130020b6 Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Tue, 24 Feb 2026 23:53:18 +0100 Subject: [PATCH 03/14] Refactor, change approach to fake storage --- app/Actions/MapHopData.php | 7 +-- app/Actions/UpsertHop.php | 10 +++- app/Console/Commands/HopsImportCommand.php | 4 +- .../Commands/HopsImportCommandTest.php | 6 --- .../Feature/Jobs/ImportHopVarietyJobTest.php | 19 +++---- tests/Fixtures/hops/minimal.json5 | 29 ----------- .../cascade.json5 => Helpers/HopFixture.php} | 49 +++++++++++++++++++ 7 files changed, 71 insertions(+), 53 deletions(-) delete mode 100644 tests/Fixtures/hops/minimal.json5 rename tests/{Fixtures/hops/cascade.json5 => Helpers/HopFixture.php} (54%) diff --git a/app/Actions/MapHopData.php b/app/Actions/MapHopData.php index f14ecf9..4d7dc1d 100644 --- a/app/Actions/MapHopData.php +++ b/app/Actions/MapHopData.php @@ -44,9 +44,10 @@ public function execute(array $data): array private function extractSubstitutes(array $data): array { $alternatives = $data["ingredients"]["alternatives"] ?? []; - $brewhouse = $alternatives["brewhouse"] ?? []; - $dryhopping = $alternatives["dryhopping"] ?? []; - return array_values(array_unique(array_merge($brewhouse, $dryhopping))); + return [ + "brewhouse" => $alternatives["brewhouse"] ?? [], + "dryhopping" => $alternatives["dryhopping"] ?? [], + ]; } } diff --git a/app/Actions/UpsertHop.php b/app/Actions/UpsertHop.php index 8e166dd..4c3a09f 100644 --- a/app/Actions/UpsertHop.php +++ b/app/Actions/UpsertHop.php @@ -55,7 +55,10 @@ private function validate(array $data): array "aroma_descriptors" => ["nullable", "array"], "aroma_descriptors.*" => ["string"], "substitutes" => ["nullable", "array"], - "substitutes.*" => ["string"], + "substitutes.brewhouse" => ["nullable", "array"], + "substitutes.brewhouse.*" => ["string"], + "substitutes.dryhopping" => ["nullable", "array"], + "substitutes.dryhopping.*" => ["string"], ]); if ($validator->fails()) { @@ -80,7 +83,10 @@ private function buildAttributes(array $validated): array "aroma_sugarlike" => $validated["aroma_sugarlike"], "aroma_miscellaneous" => $validated["aroma_miscellaneous"], "aroma_descriptors" => $validated["aroma_descriptors"] ?? [], - "substitutes" => $validated["substitutes"] ?? [], + "substitutes" => array_values(array_unique(array_merge( + $validated["substitutes"]["brewhouse"] ?? [], + $validated["substitutes"]["dryhopping"] ?? [], + ))), ]; $rangeFields = [ diff --git a/app/Console/Commands/HopsImportCommand.php b/app/Console/Commands/HopsImportCommand.php index e953a94..ffee9b2 100644 --- a/app/Console/Commands/HopsImportCommand.php +++ b/app/Console/Commands/HopsImportCommand.php @@ -28,13 +28,13 @@ public function handle(): void return; } - $this->info("Found {$files->count()} file(s) to import."); + $this->info("Found {$files->count()} hop variety file(s) to import."); foreach ($files as $file) { $this->info("Dispatching import for: {$file}"); ImportHopVarietyJob::dispatch($file); } - $this->info("All import jobs have been dispatched."); + $this->info("All import jobs have been dispatched to queue, run `php artisan queue:work` to process them."); } } diff --git a/tests/Feature/Console/Commands/HopsImportCommandTest.php b/tests/Feature/Console/Commands/HopsImportCommandTest.php index d8b019a..f5ee01f 100644 --- a/tests/Feature/Console/Commands/HopsImportCommandTest.php +++ b/tests/Feature/Console/Commands/HopsImportCommandTest.php @@ -24,10 +24,6 @@ public function testItDispatchesImportJobsForJson5Files(): void Storage::disk("local")->put("hops_data/not-a-hop.txt", "test"); $this->artisan("hops:import") - ->expectsOutput("Found 2 file(s) to import.") - ->expectsOutput("Dispatching import for: hops_data/citra.json5") - ->expectsOutput("Dispatching import for: hops_data/mosaic.json5") - ->expectsOutput("All import jobs have been dispatched.") ->assertExitCode(0); Bus::assertDispatched(ImportHopVarietyJob::class, fn($job) => $job->filePath === "hops_data/citra.json5"); @@ -45,7 +41,6 @@ public function testItAlsoDispatchesForPlainJsonFiles(): void Storage::disk("local")->put("hops_data/citra.json", "{}"); $this->artisan("hops:import") - ->expectsOutput("Found 1 file(s) to import.") ->assertExitCode(0); Bus::assertDispatched(ImportHopVarietyJob::class, 1); @@ -56,7 +51,6 @@ public function testItShowsMessageWhenNoFilesFound(): void Storage::fake("local"); $this->artisan("hops:import") - ->expectsOutput("No files found in hops_data directory.") ->assertExitCode(0); } } diff --git a/tests/Feature/Jobs/ImportHopVarietyJobTest.php b/tests/Feature/Jobs/ImportHopVarietyJobTest.php index 725a327..7eada53 100644 --- a/tests/Feature/Jobs/ImportHopVarietyJobTest.php +++ b/tests/Feature/Jobs/ImportHopVarietyJobTest.php @@ -9,6 +9,8 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use Tests\Helpers\HopFixture; use Tests\TestCase; class ImportHopVarietyJobTest extends TestCase @@ -18,7 +20,7 @@ class ImportHopVarietyJobTest extends TestCase public function testItImportsHopFromJson5File(): void { Storage::fake("local"); - Storage::disk("local")->put("hops_data/cascade.json5", $this->fixture("cascade.json5")); + Storage::disk("local")->put("hops_data/cascade.json5", HopFixture::cascade()); ImportHopVarietyJob::dispatchSync("hops_data/cascade.json5"); @@ -46,7 +48,7 @@ public function testItImportsHopFromJson5File(): void public function testItUpdatesExistingHopOnReimport(): void { Storage::fake("local"); - Storage::disk("local")->put("hops_data/cascade.json5", $this->fixture("cascade.json5")); + Storage::disk("local")->put("hops_data/cascade.json5", HopFixture::cascade()); ImportHopVarietyJob::dispatchSync("hops_data/cascade.json5"); ImportHopVarietyJob::dispatchSync("hops_data/cascade.json5"); @@ -59,7 +61,7 @@ public function testItLogsWarningForMissingFile(): void Storage::fake("local"); Log::shouldReceive("warning") ->once() - ->withArgs(fn(string $msg) => str_contains($msg, "File not found")); + ->withArgs(fn(string $msg) => Str::contains($msg, "File not found")); ImportHopVarietyJob::dispatchSync("hops_data/nonexistent.json5"); @@ -73,7 +75,7 @@ public function testItLogsWarningForInvalidJson(): void Log::shouldReceive("warning") ->once() - ->withArgs(fn(string $msg) => str_contains($msg, "Failed to parse")); + ->withArgs(fn(string $msg) => Str::contains($msg, "Failed to parse")); ImportHopVarietyJob::dispatchSync("hops_data/bad.json5"); @@ -87,7 +89,7 @@ public function testItLogsWarningForValidationFailure(): void Log::shouldReceive("warning") ->once() - ->withArgs(fn(string $msg) => str_contains($msg, "Validation failed")); + ->withArgs(fn(string $msg) => Str::contains($msg, "Validation failed")); ImportHopVarietyJob::dispatchSync("hops_data/invalid.json5"); @@ -97,7 +99,7 @@ public function testItLogsWarningForValidationFailure(): void public function testItHandlesNullIngredientRanges(): void { Storage::fake("local"); - Storage::disk("local")->put("hops_data/minimal.json5", $this->fixture("minimal.json5")); + Storage::disk("local")->put("hops_data/minimal.json5", HopFixture::minimal()); ImportHopVarietyJob::dispatchSync("hops_data/minimal.json5"); @@ -107,9 +109,4 @@ public function testItHandlesNullIngredientRanges(): void $this->assertNull($hop->beta_acid); $this->assertNull($hop->polyphenol); } - - private function fixture(string $filename): string - { - return file_get_contents(__DIR__ . "/../../Fixtures/hops/{$filename}"); - } } diff --git a/tests/Fixtures/hops/minimal.json5 b/tests/Fixtures/hops/minimal.json5 deleted file mode 100644 index 5c760e7..0000000 --- a/tests/Fixtures/hops/minimal.json5 +++ /dev/null @@ -1,29 +0,0 @@ -{ - name: "Test Hop", - country: "DE", - aroma: { - citrusy: 1, - fruity: 0, - floral: 0, - herbal: 0, - spicy: 0, - resinous: 0, - sugarlike: 0, - misc: 0 - }, - aromaDescription: [], - ingredients: { - alphas: { min: 5.0, max: 8.0 }, - betas: null, - cohumulones: null, - polyphenols: null, - xanthohumols: null, - oils: null, - farnesenes: null, - linalool: null, - alternatives: { - brewhouse: [], - dryhopping: [] - } - } -} diff --git a/tests/Fixtures/hops/cascade.json5 b/tests/Helpers/HopFixture.php similarity index 54% rename from tests/Fixtures/hops/cascade.json5 rename to tests/Helpers/HopFixture.php index 4cd1c2e..99ab7b6 100644 --- a/tests/Fixtures/hops/cascade.json5 +++ b/tests/Helpers/HopFixture.php @@ -1,3 +1,14 @@ + Date: Wed, 25 Feb 2026 00:17:42 +0100 Subject: [PATCH 04/14] feat(hops): expand schema with agronomic, lineage, thiols and alt_name fields - add alt_name, descriptors, lineage columns - add thiols (low/medium/high) - add agronomic: yield_min/max, maturity, wilt_disease, downy_mildew, powdery_mildew, aphid - rename aroma_miscellaneous -> aroma_misc - change substitutes from flat array to {brewhouse, dryhopping} JSON object - update Hop model fillable, casts and docblock accordingly --- app/Models/Hop.php | 32 ++++++++++++++++--- .../2026_02_18_224936_create_hops_table.php | 15 +++++++-- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/app/Models/Hop.php b/app/Models/Hop.php index 8eadd6c..66ea830 100644 --- a/app/Models/Hop.php +++ b/app/Models/Hop.php @@ -14,8 +14,11 @@ * @property int $id * @property string $name * @property string $slug + * @property ?string $alt_name * @property ?string $country * @property ?string $description + * @property ?array $descriptors + * @property ?array $lineage * @property ?RangeOrNumber $alpha_acid * @property ?RangeOrNumber $beta_acid * @property ?RangeOrNumber $cohumulone @@ -24,6 +27,7 @@ * @property ?RangeOrNumber $xanthohumol * @property ?RangeOrNumber $farnesene * @property ?RangeOrNumber $linalool + * @property ?string $thiols * @property ?int $aroma_citrusy * @property ?int $aroma_fruity * @property ?int $aroma_floral @@ -31,9 +35,16 @@ * @property ?int $aroma_spicy * @property ?int $aroma_resinous * @property ?int $aroma_sugarlike - * @property ?int $aroma_miscellaneous - * @property ?array $aroma_descriptors - * @property ?array $substitutes + * @property ?int $aroma_misc + * @property ?array $aroma_descriptors + * @property ?array{brewhouse: array, dryhopping: array} $substitutes + * @property ?int $yield_min + * @property ?int $yield_max + * @property ?string $maturity + * @property ?string $wilt_disease + * @property ?string $downy_mildew + * @property ?string $powdery_mildew + * @property ?string $aphid * @property ?string $bitterness * @property ?string $aromaticity * @property ?Carbon $created_at @@ -46,8 +57,11 @@ class Hop extends Model protected $fillable = [ "name", "slug", + "alt_name", "country", "description", + "descriptors", + "lineage", "alpha_acid", "beta_acid", "cohumulone", @@ -56,6 +70,7 @@ class Hop extends Model "xanthohumol", "farnesene", "linalool", + "thiols", "aroma_citrusy", "aroma_fruity", "aroma_floral", @@ -63,9 +78,16 @@ class Hop extends Model "aroma_spicy", "aroma_resinous", "aroma_sugarlike", - "aroma_miscellaneous", + "aroma_misc", "aroma_descriptors", "substitutes", + "yield_min", + "yield_max", + "maturity", + "wilt_disease", + "downy_mildew", + "powdery_mildew", + "aphid", "bitterness", "aromaticity", ]; @@ -78,6 +100,8 @@ class Hop extends Model "xanthohumol" => RangeOrNumberCast::class, "farnesene" => RangeOrNumberCast::class, "linalool" => RangeOrNumberCast::class, + "descriptors" => "array", + "lineage" => "array", "aroma_descriptors" => "array", "substitutes" => "array", ]; diff --git a/database/migrations/2026_02_18_224936_create_hops_table.php b/database/migrations/2026_02_18_224936_create_hops_table.php index 8afd2c9..f5040fe 100644 --- a/database/migrations/2026_02_18_224936_create_hops_table.php +++ b/database/migrations/2026_02_18_224936_create_hops_table.php @@ -13,8 +13,11 @@ public function up(): void $table->id(); $table->string("name")->unique(); $table->string("slug")->unique(); + $table->string("alt_name")->nullable(); $table->string("country")->nullable(); $table->text("description")->nullable(); + $table->json("descriptors")->nullable(); + $table->json("lineage")->nullable(); $table->decimal("alpha_acid_min", places: 4)->nullable(); $table->decimal("alpha_acid_max", places: 4)->nullable(); $table->decimal("beta_acid_min", places: 4)->nullable(); @@ -31,6 +34,7 @@ public function up(): void $table->decimal("farnesene_max", places: 4)->nullable(); $table->decimal("linalool_min", places: 4)->nullable(); $table->decimal("linalool_max", places: 4)->nullable(); + $table->string("thiols")->nullable(); $table->tinyInteger("aroma_citrusy")->nullable(); $table->tinyInteger("aroma_fruity")->nullable(); $table->tinyInteger("aroma_floral")->nullable(); @@ -38,9 +42,16 @@ public function up(): void $table->tinyInteger("aroma_spicy")->nullable(); $table->tinyInteger("aroma_resinous")->nullable(); $table->tinyInteger("aroma_sugarlike")->nullable(); - $table->tinyInteger("aroma_miscellaneous")->nullable(); - $table->json("aroma_descriptors")->nullable(); + $table->tinyInteger("aroma_misc")->nullable(); + $table->json("aroma_descriptors")->nullable(); $table->json("substitutes")->nullable(); + $table->integer("yield_min")->nullable(); + $table->integer("yield_max")->nullable(); + $table->string("maturity")->nullable(); + $table->string("wilt_disease")->nullable(); + $table->string("downy_mildew")->nullable(); + $table->string("powdery_mildew")->nullable(); + $table->string("aphid")->nullable(); $table->string("bitterness")->nullable(); $table->string("aromaticity")->nullable(); $table->timestamps(); From 34a94cdafdf9a1a05f9f0c44f9079bd11ef53706 Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Wed, 25 Feb 2026 00:17:52 +0100 Subject: [PATCH 05/14] feat(hops): map and validate all new hop fields in import pipeline - MapHopData: extract alt_name, descriptors, lineage, thiols, all agronomic fields - MapHopData: store substitutes as {brewhouse, dryhopping} nested structure - UpsertHop: add validation rules for all new fields (thiols: in:low,medium,high) - UpsertHop: buildAttributes stores substitutes with brewhouse/dryhopping keys preserved --- app/Actions/MapHopData.php | 16 ++++++++++- app/Actions/UpsertHop.php | 54 +++++++++++++++++++++++++++----------- 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/app/Actions/MapHopData.php b/app/Actions/MapHopData.php index 4d7dc1d..04c7ea1 100644 --- a/app/Actions/MapHopData.php +++ b/app/Actions/MapHopData.php @@ -8,10 +8,16 @@ class MapHopData { public function execute(array $data): array { + $agronomic = $data["agronomic"] ?? []; + $yield = $agronomic["yield"] ?? null; + return [ "name" => $data["name"] ?? null, + "alt_name" => $data["altName"] ?? null, "country" => $data["country"] ?? null, "description" => $data["origin"] ?? null, + "descriptors" => $data["descriptors"] ?? [], + "lineage" => $data["lineage"] ?? [], "alpha_acid_min" => $data["ingredients"]["alphas"]["min"] ?? null, "alpha_acid_max" => $data["ingredients"]["alphas"]["max"] ?? null, "beta_acid_min" => $data["ingredients"]["betas"]["min"] ?? null, @@ -28,6 +34,7 @@ public function execute(array $data): array "farnesene_max" => $data["ingredients"]["farnesenes"]["max"] ?? null, "linalool_min" => $data["ingredients"]["linalool"]["min"] ?? null, "linalool_max" => $data["ingredients"]["linalool"]["max"] ?? null, + "thiols" => $data["ingredients"]["thiols"] ?? null, "aroma_citrusy" => $data["aroma"]["citrusy"] ?? null, "aroma_fruity" => $data["aroma"]["fruity"] ?? null, "aroma_floral" => $data["aroma"]["floral"] ?? null, @@ -35,8 +42,15 @@ public function execute(array $data): array "aroma_spicy" => $data["aroma"]["spicy"] ?? null, "aroma_resinous" => $data["aroma"]["resinous"] ?? null, "aroma_sugarlike" => $data["aroma"]["sugarlike"] ?? null, - "aroma_miscellaneous" => $data["aroma"]["misc"] ?? null, + "aroma_misc" => $data["aroma"]["misc"] ?? null, "aroma_descriptors" => $data["aromaDescription"] ?? [], + "yield_min" => is_array($yield) ? ($yield["min"] ?? null) : null, + "yield_max" => is_array($yield) ? ($yield["max"] ?? null) : null, + "maturity" => $agronomic["maturity"] ?? null, + "wilt_disease" => $agronomic["wiltDisease"] ?? null, + "downy_mildew" => $agronomic["downyMildew"] ?? null, + "powdery_mildew" => $agronomic["powderyMildew"] ?? null, + "aphid" => $agronomic["aphid"] ?? null, "substitutes" => $this->extractSubstitutes($data), ]; } diff --git a/app/Actions/UpsertHop.php b/app/Actions/UpsertHop.php index 4c3a09f..46fac8a 100644 --- a/app/Actions/UpsertHop.php +++ b/app/Actions/UpsertHop.php @@ -26,8 +26,13 @@ private function validate(array $data): array { $validator = Validator::make($data, [ "name" => ["required", "string", "max:255"], + "alt_name" => ["nullable", "string", "max:255"], "country" => ["nullable", "string", "max:255"], "description" => ["nullable", "string"], + "descriptors" => ["nullable", "array"], + "descriptors.*" => ["string"], + "lineage" => ["nullable", "array"], + "lineage.*" => ["string"], "alpha_acid_min" => ["nullable", "numeric", "min:0"], "alpha_acid_max" => ["nullable", "numeric", "min:0"], "beta_acid_min" => ["nullable", "numeric", "min:0"], @@ -44,6 +49,7 @@ private function validate(array $data): array "farnesene_max" => ["nullable", "numeric", "min:0"], "linalool_min" => ["nullable", "numeric", "min:0"], "linalool_max" => ["nullable", "numeric", "min:0"], + "thiols" => ["nullable", "string", "in:low,medium,high"], "aroma_citrusy" => ["nullable", "integer", "between:0,5"], "aroma_fruity" => ["nullable", "integer", "between:0,5"], "aroma_floral" => ["nullable", "integer", "between:0,5"], @@ -51,7 +57,7 @@ private function validate(array $data): array "aroma_spicy" => ["nullable", "integer", "between:0,5"], "aroma_resinous" => ["nullable", "integer", "between:0,5"], "aroma_sugarlike" => ["nullable", "integer", "between:0,5"], - "aroma_miscellaneous" => ["nullable", "integer", "between:0,5"], + "aroma_misc" => ["nullable", "integer", "between:0,5"], "aroma_descriptors" => ["nullable", "array"], "aroma_descriptors.*" => ["string"], "substitutes" => ["nullable", "array"], @@ -59,6 +65,13 @@ private function validate(array $data): array "substitutes.brewhouse.*" => ["string"], "substitutes.dryhopping" => ["nullable", "array"], "substitutes.dryhopping.*" => ["string"], + "yield_min" => ["nullable", "integer", "min:0"], + "yield_max" => ["nullable", "integer", "min:0"], + "maturity" => ["nullable", "string", "max:255"], + "wilt_disease" => ["nullable", "string", "max:255"], + "downy_mildew" => ["nullable", "string", "max:255"], + "powdery_mildew" => ["nullable", "string", "max:255"], + "aphid" => ["nullable", "string", "max:255"], ]); if ($validator->fails()) { @@ -72,21 +85,32 @@ private function buildAttributes(array $validated): array { $attributes = [ "slug" => Str::slug($validated["name"]) . "-hop", - "country" => $validated["country"], - "description" => $validated["description"], - "aroma_citrusy" => $validated["aroma_citrusy"], - "aroma_fruity" => $validated["aroma_fruity"], - "aroma_floral" => $validated["aroma_floral"], - "aroma_herbal" => $validated["aroma_herbal"], - "aroma_spicy" => $validated["aroma_spicy"], - "aroma_resinous" => $validated["aroma_resinous"], - "aroma_sugarlike" => $validated["aroma_sugarlike"], - "aroma_miscellaneous" => $validated["aroma_miscellaneous"], + "alt_name" => $validated["alt_name"] ?? null, + "country" => $validated["country"] ?? null, + "description" => $validated["description"] ?? null, + "descriptors" => $validated["descriptors"] ?? [], + "lineage" => $validated["lineage"] ?? [], + "thiols" => $validated["thiols"] ?? null, + "aroma_citrusy" => $validated["aroma_citrusy"] ?? null, + "aroma_fruity" => $validated["aroma_fruity"] ?? null, + "aroma_floral" => $validated["aroma_floral"] ?? null, + "aroma_herbal" => $validated["aroma_herbal"] ?? null, + "aroma_spicy" => $validated["aroma_spicy"] ?? null, + "aroma_resinous" => $validated["aroma_resinous"] ?? null, + "aroma_sugarlike" => $validated["aroma_sugarlike"] ?? null, + "aroma_misc" => $validated["aroma_misc"] ?? null, "aroma_descriptors" => $validated["aroma_descriptors"] ?? [], - "substitutes" => array_values(array_unique(array_merge( - $validated["substitutes"]["brewhouse"] ?? [], - $validated["substitutes"]["dryhopping"] ?? [], - ))), + "substitutes" => [ + "brewhouse" => $validated["substitutes"]["brewhouse"] ?? [], + "dryhopping" => $validated["substitutes"]["dryhopping"] ?? [], + ], + "yield_min" => $validated["yield_min"] ?? null, + "yield_max" => $validated["yield_max"] ?? null, + "maturity" => $validated["maturity"] ?? null, + "wilt_disease" => $validated["wilt_disease"] ?? null, + "downy_mildew" => $validated["downy_mildew"] ?? null, + "powdery_mildew" => $validated["powdery_mildew"] ?? null, + "aphid" => $validated["aphid"] ?? null, ]; $rangeFields = [ From a6acc442656db91d1cd4d488d7b82ac964a93031 Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Wed, 25 Feb 2026 00:18:01 +0100 Subject: [PATCH 06/14] test(hops): update tests and factory for new schema - ImportHopVarietyJobTest: assert aroma_misc (renamed from aroma_miscellaneous) - ImportHopVarietyJobTest: assert substitutes as nested brewhouse/dryhopping structure - HopFactory: add all missing fields (alt_name, descriptors, lineage, thiols, agronomic) - HopFactory: fix substitutes to use correct nested {brewhouse, dryhopping} format - HopFactory: use countryCode() instead of country() for realistic 2-letter codes --- database/factories/HopFactory.php | 30 +++++++++++++++---- .../Feature/Jobs/ImportHopVarietyJobTest.php | 3 +- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/database/factories/HopFactory.php b/database/factories/HopFactory.php index 857668a..69774e0 100644 --- a/database/factories/HopFactory.php +++ b/database/factories/HopFactory.php @@ -24,12 +24,21 @@ public function definition(): array $xanthohumolMin = $this->faker->randomFloat(1, 0.1, 1.0); $farneseneMin = $this->faker->randomFloat(1, 0.1, 10); $linaloolMin = $this->faker->randomFloat(1, 0.1, 1.5); + $yieldMin = $this->faker->numberBetween(800, 2000); + + $substituteSlugs = fn(): array => $this->faker->randomElements( + ["cascade", "centennial", "chinook", "citra", "mosaic", "simcoe", "amarillo"], + $this->faker->numberBetween(0, 3), + ); return [ "name" => $this->faker->unique()->word() . " Hop", "slug" => $this->faker->unique()->slug(), - "country" => $this->faker->country(), + "alt_name" => $this->faker->optional()->word(), + "country" => $this->faker->countryCode(), "description" => $this->faker->paragraph(), + "descriptors" => $this->faker->randomElements(["fruity", "citrusy", "herbal", "spicy", "floral", "resinous"], 2), + "lineage" => $this->faker->optional()->randomElements(["brewers-gold", "fuggle", "cascade"], 2), "alpha_acid_min" => $alphaMin, "alpha_acid_max" => $alphaMin + $this->faker->randomFloat(1, 1, 5), "beta_acid_min" => $betaMin, @@ -46,6 +55,7 @@ public function definition(): array "farnesene_max" => $farneseneMin + $this->faker->randomFloat(1, 1, 5), "linalool_min" => $linaloolMin, "linalool_max" => $linaloolMin + $this->faker->randomFloat(1, 0.1, 0.5), + "thiols" => $this->faker->randomElement(["low", "medium", "high"]), "aroma_citrusy" => $this->faker->numberBetween(0, 5), "aroma_fruity" => $this->faker->numberBetween(0, 5), "aroma_floral" => $this->faker->numberBetween(0, 5), @@ -53,11 +63,21 @@ public function definition(): array "aroma_spicy" => $this->faker->numberBetween(0, 5), "aroma_resinous" => $this->faker->numberBetween(0, 5), "aroma_sugarlike" => $this->faker->numberBetween(0, 5), - "aroma_miscellaneous" => $this->faker->numberBetween(0, 5), + "aroma_misc" => $this->faker->numberBetween(0, 5), "aroma_descriptors" => $this->faker->words(3), - "substitutes" => $this->faker->words(3), - "bitterness" => $this->faker->randomElement(Bitterness::values()), - "aromaticity" => $this->faker->randomElement(Aromaticity::values()), + "substitutes" => [ + "brewhouse" => $substituteSlugs(), + "dryhopping" => $substituteSlugs(), + ], + "yield_min" => $yieldMin, + "yield_max" => $yieldMin + $this->faker->numberBetween(100, 500), + "maturity" => $this->faker->optional()->randomElement(["early", "mid early", "mid late", "late", "very late"]), + "wilt_disease" => $this->faker->optional()->randomElement(["resistant", "tolerant", "susceptible"]), + "downy_mildew" => $this->faker->optional()->randomElement(["resistant", "tolerant", "susceptible"]), + "powdery_mildew" => $this->faker->optional()->randomElement(["resistant", "tolerant", "susceptible"]), + "aphid" => $this->faker->optional()->randomElement(["resistant", "tolerant", "susceptible"]), + "bitterness" => $this->faker->optional()->randomElement(Bitterness::values()), + "aromaticity" => $this->faker->optional()->randomElement(Aromaticity::values()), ]; } } diff --git a/tests/Feature/Jobs/ImportHopVarietyJobTest.php b/tests/Feature/Jobs/ImportHopVarietyJobTest.php index 7eada53..a128e30 100644 --- a/tests/Feature/Jobs/ImportHopVarietyJobTest.php +++ b/tests/Feature/Jobs/ImportHopVarietyJobTest.php @@ -37,8 +37,9 @@ public function testItImportsHopFromJson5File(): void $this->assertEquals(3, $hop->aroma_fruity); $this->assertEquals(1, $hop->aroma_floral); $this->assertEquals(0, $hop->aroma_spicy); + $this->assertEquals(0, $hop->aroma_misc); $this->assertEquals(["lime", "black currant"], $hop->aroma_descriptors); - $this->assertEquals(["centennial", "lemondrop"], $hop->substitutes); + $this->assertEquals(["brewhouse" => ["centennial", "lemondrop"], "dryhopping" => ["centennial", "lemondrop"]], $hop->substitutes); $this->assertNotNull($hop->alpha_acid); $this->assertEquals(4.5, $hop->alpha_acid->min); $this->assertEquals(7.0, $hop->alpha_acid->max); From cb71a4b0ca0d4a7fe2652eff099babb2d0e32c29 Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Wed, 25 Feb 2026 00:20:10 +0100 Subject: [PATCH 07/14] Remove unnecessary config --- .gitignore | 1 - eslint.config.js | 3 -- storage/framework/testing/disks/.gitkeep | 0 .../disks/local/hops_data/minimal.json5 | 29 +++++++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 storage/framework/testing/disks/.gitkeep create mode 100644 storage/framework/testing/disks/local/hops_data/minimal.json5 diff --git a/.gitignore b/.gitignore index e6b9666..07a39ea 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,3 @@ supervisord.pid .php-cs-fixer.cache phpunit.xml /database/sqlite -/storage/framework/testing diff --git a/eslint.config.js b/eslint.config.js index 489fed9..a71a506 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,7 +2,4 @@ import blumilkDefault from '@blumilksoftware/eslint-config' export default [ ...blumilkDefault, - { - ignores: ['storage/**'], - }, ] diff --git a/storage/framework/testing/disks/.gitkeep b/storage/framework/testing/disks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/framework/testing/disks/local/hops_data/minimal.json5 b/storage/framework/testing/disks/local/hops_data/minimal.json5 new file mode 100644 index 0000000..6869c06 --- /dev/null +++ b/storage/framework/testing/disks/local/hops_data/minimal.json5 @@ -0,0 +1,29 @@ +{ + name: "Test Hop", + country: "DE", + aroma: { + citrusy: 1, + fruity: 0, + floral: 0, + herbal: 0, + spicy: 0, + resinous: 0, + sugarlike: 0, + misc: 0 + }, + aromaDescription: [], + ingredients: { + alphas: { min: 5.0, max: 8.0 }, + betas: null, + cohumulones: null, + polyphenols: null, + xanthohumols: null, + oils: null, + farnesenes: null, + linalool: null, + alternatives: { + brewhouse: [], + dryhopping: [] + } + } +} \ No newline at end of file From 8f82c4060e7464fe476e42a6f7564b205599575e Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Wed, 25 Feb 2026 00:24:25 +0100 Subject: [PATCH 08/14] remove test files leftovers --- storage/framework/testing/disks/.gitkeep | 0 .../disks/local/hops_data/minimal.json5 | 29 ------------------- 2 files changed, 29 deletions(-) delete mode 100644 storage/framework/testing/disks/.gitkeep delete mode 100644 storage/framework/testing/disks/local/hops_data/minimal.json5 diff --git a/storage/framework/testing/disks/.gitkeep b/storage/framework/testing/disks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/storage/framework/testing/disks/local/hops_data/minimal.json5 b/storage/framework/testing/disks/local/hops_data/minimal.json5 deleted file mode 100644 index 6869c06..0000000 --- a/storage/framework/testing/disks/local/hops_data/minimal.json5 +++ /dev/null @@ -1,29 +0,0 @@ -{ - name: "Test Hop", - country: "DE", - aroma: { - citrusy: 1, - fruity: 0, - floral: 0, - herbal: 0, - spicy: 0, - resinous: 0, - sugarlike: 0, - misc: 0 - }, - aromaDescription: [], - ingredients: { - alphas: { min: 5.0, max: 8.0 }, - betas: null, - cohumulones: null, - polyphenols: null, - xanthohumols: null, - oils: null, - farnesenes: null, - linalool: null, - alternatives: { - brewhouse: [], - dryhopping: [] - } - } -} \ No newline at end of file From 42277e152c08dbd09b0a853077e8f23d1f163d17 Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Wed, 25 Feb 2026 00:26:51 +0100 Subject: [PATCH 09/14] Add parameter to specify folder for import --- app/Console/Commands/HopsImportCommand.php | 8 +++-- .../disks/local/hops_data/minimal.json5 | 29 +++++++++++++++++++ .../Commands/HopsImportCommandTest.php | 13 +++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 storage/framework/testing/disks/local/hops_data/minimal.json5 diff --git a/app/Console/Commands/HopsImportCommand.php b/app/Console/Commands/HopsImportCommand.php index ffee9b2..2529e4e 100644 --- a/app/Console/Commands/HopsImportCommand.php +++ b/app/Console/Commands/HopsImportCommand.php @@ -10,12 +10,14 @@ class HopsImportCommand extends Command { - protected $signature = "hops:import"; + protected $signature = "hops:import {folder=hops_data : Directory inside local storage to scan for JSON/JSON5 files}"; protected $description = "Import hop varieties from JSON/JSON5 files in storage"; public function handle(): void { - $files = collect(Storage::disk("local")->files("hops_data")) + $folder = $this->argument("folder"); + + $files = collect(Storage::disk("local")->files($folder)) ->filter(fn(string $file): bool => in_array( pathinfo($file, PATHINFO_EXTENSION), ["json", "json5"], @@ -23,7 +25,7 @@ public function handle(): void )); if ($files->isEmpty()) { - $this->info("No files found in hops_data directory."); + $this->info("No files found in {$folder} directory."); return; } diff --git a/storage/framework/testing/disks/local/hops_data/minimal.json5 b/storage/framework/testing/disks/local/hops_data/minimal.json5 new file mode 100644 index 0000000..6869c06 --- /dev/null +++ b/storage/framework/testing/disks/local/hops_data/minimal.json5 @@ -0,0 +1,29 @@ +{ + name: "Test Hop", + country: "DE", + aroma: { + citrusy: 1, + fruity: 0, + floral: 0, + herbal: 0, + spicy: 0, + resinous: 0, + sugarlike: 0, + misc: 0 + }, + aromaDescription: [], + ingredients: { + alphas: { min: 5.0, max: 8.0 }, + betas: null, + cohumulones: null, + polyphenols: null, + xanthohumols: null, + oils: null, + farnesenes: null, + linalool: null, + alternatives: { + brewhouse: [], + dryhopping: [] + } + } +} \ No newline at end of file diff --git a/tests/Feature/Console/Commands/HopsImportCommandTest.php b/tests/Feature/Console/Commands/HopsImportCommandTest.php index f5ee01f..0bd2c68 100644 --- a/tests/Feature/Console/Commands/HopsImportCommandTest.php +++ b/tests/Feature/Console/Commands/HopsImportCommandTest.php @@ -53,4 +53,17 @@ public function testItShowsMessageWhenNoFilesFound(): void $this->artisan("hops:import") ->assertExitCode(0); } + + public function testItAcceptsCustomFolderArgument(): void + { + Storage::fake("local"); + Bus::fake(); + + Storage::disk("local")->put("custom_folder/galaxy.json5", "{}"); + + $this->artisan("hops:import", ["folder" => "custom_folder"]) + ->assertExitCode(0); + + Bus::assertDispatched(ImportHopVarietyJob::class, fn($job) => $job->filePath === "custom_folder/galaxy.json5"); + } } From 448aaf30835fc4b5b7ee64480c90658f9425f3f2 Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Wed, 25 Feb 2026 00:28:40 +0100 Subject: [PATCH 10/14] chore: ignore storage/framework/testing/disks from git Storage::fake() creates real files in this directory during tests and does not clean them up automatically after each run. --- .gitignore | 1 + .../disks/local/hops_data/minimal.json5 | 29 ------------------- 2 files changed, 1 insertion(+), 29 deletions(-) delete mode 100644 storage/framework/testing/disks/local/hops_data/minimal.json5 diff --git a/.gitignore b/.gitignore index 07a39ea..5418a4b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ supervisord.pid .php-cs-fixer.cache phpunit.xml /database/sqlite +/storage/framework/testing/disks/ diff --git a/storage/framework/testing/disks/local/hops_data/minimal.json5 b/storage/framework/testing/disks/local/hops_data/minimal.json5 deleted file mode 100644 index 6869c06..0000000 --- a/storage/framework/testing/disks/local/hops_data/minimal.json5 +++ /dev/null @@ -1,29 +0,0 @@ -{ - name: "Test Hop", - country: "DE", - aroma: { - citrusy: 1, - fruity: 0, - floral: 0, - herbal: 0, - spicy: 0, - resinous: 0, - sugarlike: 0, - misc: 0 - }, - aromaDescription: [], - ingredients: { - alphas: { min: 5.0, max: 8.0 }, - betas: null, - cohumulones: null, - polyphenols: null, - xanthohumols: null, - oils: null, - farnesenes: null, - linalool: null, - alternatives: { - brewhouse: [], - dryhopping: [] - } - } -} \ No newline at end of file From 7c96d58c93ab97f0a5cf1ddd8a6a18935ada2c6b Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Mon, 2 Mar 2026 13:22:29 +0100 Subject: [PATCH 11/14] Apply suggestions --- app/Actions/MapHopData.php | 49 ++++++++---- app/Actions/UpsertHop.php | 80 +++++++++---------- app/Console/Commands/HopsImportCommand.php | 8 +- app/Jobs/ImportHopVarietyJob.php | 6 +- .../Commands/HopsImportCommandTest.php | 10 +-- .../Feature/Jobs/ImportHopVarietyJobTest.php | 6 +- 6 files changed, 83 insertions(+), 76 deletions(-) diff --git a/app/Actions/MapHopData.php b/app/Actions/MapHopData.php index 04c7ea1..6dda402 100644 --- a/app/Actions/MapHopData.php +++ b/app/Actions/MapHopData.php @@ -4,10 +4,13 @@ namespace HopsWeb\Actions; +use HopsWeb\ValueObjects\RangeOrNumber; + class MapHopData { public function execute(array $data): array { + $ingredients = $data["ingredients"] ?? []; $agronomic = $data["agronomic"] ?? []; $yield = $agronomic["yield"] ?? null; @@ -18,23 +21,15 @@ public function execute(array $data): array "description" => $data["origin"] ?? null, "descriptors" => $data["descriptors"] ?? [], "lineage" => $data["lineage"] ?? [], - "alpha_acid_min" => $data["ingredients"]["alphas"]["min"] ?? null, - "alpha_acid_max" => $data["ingredients"]["alphas"]["max"] ?? null, - "beta_acid_min" => $data["ingredients"]["betas"]["min"] ?? null, - "beta_acid_max" => $data["ingredients"]["betas"]["max"] ?? null, - "cohumulone_min" => $data["ingredients"]["cohumulones"]["min"] ?? null, - "cohumulone_max" => $data["ingredients"]["cohumulones"]["max"] ?? null, - "total_oil_min" => $data["ingredients"]["oils"]["min"] ?? null, - "total_oil_max" => $data["ingredients"]["oils"]["max"] ?? null, - "polyphenol_min" => $data["ingredients"]["polyphenols"]["min"] ?? null, - "polyphenol_max" => $data["ingredients"]["polyphenols"]["max"] ?? null, - "xanthohumol_min" => $data["ingredients"]["xanthohumols"]["min"] ?? null, - "xanthohumol_max" => $data["ingredients"]["xanthohumols"]["max"] ?? null, - "farnesene_min" => $data["ingredients"]["farnesenes"]["min"] ?? null, - "farnesene_max" => $data["ingredients"]["farnesenes"]["max"] ?? null, - "linalool_min" => $data["ingredients"]["linalool"]["min"] ?? null, - "linalool_max" => $data["ingredients"]["linalool"]["max"] ?? null, - "thiols" => $data["ingredients"]["thiols"] ?? null, + "alpha_acid" => $this->extractRange($ingredients["alphas"] ?? null), + "beta_acid" => $this->extractRange($ingredients["betas"] ?? null), + "cohumulone" => $this->extractRange($ingredients["cohumulones"] ?? null), + "total_oil" => $this->extractRange($ingredients["oils"] ?? null), + "polyphenol" => $this->extractRange($ingredients["polyphenols"] ?? null), + "xanthohumol" => $this->extractRange($ingredients["xanthohumols"] ?? null), + "farnesene" => $this->extractRange($ingredients["farnesenes"] ?? null), + "linalool" => $this->extractRange($ingredients["linalool"] ?? null), + "thiols" => $ingredients["thiols"] ?? null, "aroma_citrusy" => $data["aroma"]["citrusy"] ?? null, "aroma_fruity" => $data["aroma"]["fruity"] ?? null, "aroma_floral" => $data["aroma"]["floral"] ?? null, @@ -55,6 +50,26 @@ public function execute(array $data): array ]; } + private function extractRange(?array $ingredientData): ?RangeOrNumber + { + if ($ingredientData === null) { + return null; + } + + $min = $ingredientData["min"] ?? null; + $max = $ingredientData["max"] ?? null; + + if ($min === null && $max === null) { + return null; + } + + if ($min !== null && $max !== null) { + return RangeOrNumber::fromRange((float)$min, (float)$max); + } + + return RangeOrNumber::fromNumber((float)($min ?? $max)); + } + private function extractSubstitutes(array $data): array { $alternatives = $data["ingredients"]["alternatives"] ?? []; diff --git a/app/Actions/UpsertHop.php b/app/Actions/UpsertHop.php index 46fac8a..f5e4c52 100644 --- a/app/Actions/UpsertHop.php +++ b/app/Actions/UpsertHop.php @@ -5,26 +5,50 @@ namespace HopsWeb\Actions; use HopsWeb\Models\Hop; -use HopsWeb\ValueObjects\RangeOrNumber; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; class UpsertHop { + private const array RANGE_FIELDS = [ + "alpha_acid", + "beta_acid", + "cohumulone", + "total_oil", + "polyphenol", + "xanthohumol", + "farnesene", + "linalool", + ]; + public function execute(array $data): Hop { + $rangeValues = $this->extractRangeValues($data); $validated = $this->validate($data); return Hop::updateOrCreate( ["name" => $validated["name"]], - $this->buildAttributes($validated), + $this->buildAttributes($validated, $rangeValues), ); } + private function extractRangeValues(array $data): array + { + $rangeValues = []; + + foreach (self::RANGE_FIELDS as $field) { + $rangeValues[$field] = $data[$field] ?? null; + } + + return $rangeValues; + } + private function validate(array $data): array { - $validator = Validator::make($data, [ + $filteredData = array_diff_key($data, array_flip(self::RANGE_FIELDS)); + + $validator = Validator::make($filteredData, [ "name" => ["required", "string", "max:255"], "alt_name" => ["nullable", "string", "max:255"], "country" => ["nullable", "string", "max:255"], @@ -33,22 +57,6 @@ private function validate(array $data): array "descriptors.*" => ["string"], "lineage" => ["nullable", "array"], "lineage.*" => ["string"], - "alpha_acid_min" => ["nullable", "numeric", "min:0"], - "alpha_acid_max" => ["nullable", "numeric", "min:0"], - "beta_acid_min" => ["nullable", "numeric", "min:0"], - "beta_acid_max" => ["nullable", "numeric", "min:0"], - "cohumulone_min" => ["nullable", "numeric", "min:0"], - "cohumulone_max" => ["nullable", "numeric", "min:0"], - "total_oil_min" => ["nullable", "numeric", "min:0"], - "total_oil_max" => ["nullable", "numeric", "min:0"], - "polyphenol_min" => ["nullable", "numeric", "min:0"], - "polyphenol_max" => ["nullable", "numeric", "min:0"], - "xanthohumol_min" => ["nullable", "numeric", "min:0"], - "xanthohumol_max" => ["nullable", "numeric", "min:0"], - "farnesene_min" => ["nullable", "numeric", "min:0"], - "farnesene_max" => ["nullable", "numeric", "min:0"], - "linalool_min" => ["nullable", "numeric", "min:0"], - "linalool_max" => ["nullable", "numeric", "min:0"], "thiols" => ["nullable", "string", "in:low,medium,high"], "aroma_citrusy" => ["nullable", "integer", "between:0,5"], "aroma_fruity" => ["nullable", "integer", "between:0,5"], @@ -81,15 +89,23 @@ private function validate(array $data): array return $validator->validated(); } - private function buildAttributes(array $validated): array + private function buildAttributes(array $validated, array $rangeValues): array { - $attributes = [ + return [ "slug" => Str::slug($validated["name"]) . "-hop", "alt_name" => $validated["alt_name"] ?? null, "country" => $validated["country"] ?? null, "description" => $validated["description"] ?? null, "descriptors" => $validated["descriptors"] ?? [], "lineage" => $validated["lineage"] ?? [], + "alpha_acid" => $rangeValues["alpha_acid"], + "beta_acid" => $rangeValues["beta_acid"], + "cohumulone" => $rangeValues["cohumulone"], + "total_oil" => $rangeValues["total_oil"], + "polyphenol" => $rangeValues["polyphenol"], + "xanthohumol" => $rangeValues["xanthohumol"], + "farnesene" => $rangeValues["farnesene"], + "linalool" => $rangeValues["linalool"], "thiols" => $validated["thiols"] ?? null, "aroma_citrusy" => $validated["aroma_citrusy"] ?? null, "aroma_fruity" => $validated["aroma_fruity"] ?? null, @@ -112,27 +128,5 @@ private function buildAttributes(array $validated): array "powdery_mildew" => $validated["powdery_mildew"] ?? null, "aphid" => $validated["aphid"] ?? null, ]; - - $rangeFields = [ - "alpha_acid", - "beta_acid", - "cohumulone", - "total_oil", - "polyphenol", - "xanthohumol", - "farnesene", - "linalool", - ]; - - foreach ($rangeFields as $field) { - $min = $validated["{$field}_min"] ?? null; - $max = $validated["{$field}_max"] ?? null; - - $attributes[$field] = ($min !== null && $max !== null) - ? RangeOrNumber::fromRange((float)$min, (float)$max) - : null; - } - - return $attributes; } } diff --git a/app/Console/Commands/HopsImportCommand.php b/app/Console/Commands/HopsImportCommand.php index 2529e4e..3c1ebaf 100644 --- a/app/Console/Commands/HopsImportCommand.php +++ b/app/Console/Commands/HopsImportCommand.php @@ -6,18 +6,18 @@ use HopsWeb\Jobs\ImportHopVarietyJob; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Storage; +use Illuminate\Filesystem\FilesystemManager; class HopsImportCommand extends Command { - protected $signature = "hops:import {folder=hops_data : Directory inside local storage to scan for JSON/JSON5 files}"; + protected $signature = "hops:import {folder=hops_data : Directory inside storage to scan for JSON/JSON5 files}"; protected $description = "Import hop varieties from JSON/JSON5 files in storage"; - public function handle(): void + public function handle(FilesystemManager $filesystem): void { $folder = $this->argument("folder"); - $files = collect(Storage::disk("local")->files($folder)) + $files = collect($filesystem->files($folder)) ->filter(fn(string $file): bool => in_array( pathinfo($file, PATHINFO_EXTENSION), ["json", "json5"], diff --git a/app/Jobs/ImportHopVarietyJob.php b/app/Jobs/ImportHopVarietyJob.php index 337447e..3e464ac 100644 --- a/app/Jobs/ImportHopVarietyJob.php +++ b/app/Jobs/ImportHopVarietyJob.php @@ -9,11 +9,11 @@ use HopsWeb\Helpers\Json5Parser; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Filesystem\FilesystemManager; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; class ImportHopVarietyJob implements ShouldQueue @@ -27,9 +27,9 @@ public function __construct( public readonly string $filePath, ) {} - public function handle(Json5Parser $parser, MapHopData $mapper, UpsertHop $upsert): void + public function handle(Json5Parser $parser, MapHopData $mapper, UpsertHop $upsert, FilesystemManager $filesystem): void { - $content = Storage::disk("local")->get($this->filePath); + $content = $filesystem->get($this->filePath); if ($content === null) { Log::warning("ImportHopVarietyJob: File not found: {$this->filePath}"); diff --git a/tests/Feature/Console/Commands/HopsImportCommandTest.php b/tests/Feature/Console/Commands/HopsImportCommandTest.php index 0bd2c68..14c473e 100644 --- a/tests/Feature/Console/Commands/HopsImportCommandTest.php +++ b/tests/Feature/Console/Commands/HopsImportCommandTest.php @@ -26,11 +26,9 @@ public function testItDispatchesImportJobsForJson5Files(): void $this->artisan("hops:import") ->assertExitCode(0); - Bus::assertDispatched(ImportHopVarietyJob::class, fn($job) => $job->filePath === "hops_data/citra.json5"); - - Bus::assertDispatched(ImportHopVarietyJob::class, fn($job) => $job->filePath === "hops_data/mosaic.json5"); - - Bus::assertNotDispatched(ImportHopVarietyJob::class, fn($job) => $job->filePath === "hops_data/not-a-hop.txt"); + Bus::assertDispatched(ImportHopVarietyJob::class, fn(ImportHopVarietyJob $job): bool => $job->filePath === "hops_data/citra.json5"); + Bus::assertDispatched(ImportHopVarietyJob::class, fn(ImportHopVarietyJob $job): bool => $job->filePath === "hops_data/mosaic.json5"); + Bus::assertNotDispatched(ImportHopVarietyJob::class, fn(ImportHopVarietyJob $job): bool => $job->filePath === "hops_data/not-a-hop.txt"); } public function testItAlsoDispatchesForPlainJsonFiles(): void @@ -64,6 +62,6 @@ public function testItAcceptsCustomFolderArgument(): void $this->artisan("hops:import", ["folder" => "custom_folder"]) ->assertExitCode(0); - Bus::assertDispatched(ImportHopVarietyJob::class, fn($job) => $job->filePath === "custom_folder/galaxy.json5"); + Bus::assertDispatched(ImportHopVarietyJob::class, fn(ImportHopVarietyJob $job): bool => $job->filePath === "custom_folder/galaxy.json5"); } } diff --git a/tests/Feature/Jobs/ImportHopVarietyJobTest.php b/tests/Feature/Jobs/ImportHopVarietyJobTest.php index a128e30..9535dbc 100644 --- a/tests/Feature/Jobs/ImportHopVarietyJobTest.php +++ b/tests/Feature/Jobs/ImportHopVarietyJobTest.php @@ -62,7 +62,7 @@ public function testItLogsWarningForMissingFile(): void Storage::fake("local"); Log::shouldReceive("warning") ->once() - ->withArgs(fn(string $msg) => Str::contains($msg, "File not found")); + ->withArgs(fn(string $msg): bool => Str::contains($msg, "File not found")); ImportHopVarietyJob::dispatchSync("hops_data/nonexistent.json5"); @@ -76,7 +76,7 @@ public function testItLogsWarningForInvalidJson(): void Log::shouldReceive("warning") ->once() - ->withArgs(fn(string $msg) => Str::contains($msg, "Failed to parse")); + ->withArgs(fn(string $msg): bool => Str::contains($msg, "Failed to parse")); ImportHopVarietyJob::dispatchSync("hops_data/bad.json5"); @@ -90,7 +90,7 @@ public function testItLogsWarningForValidationFailure(): void Log::shouldReceive("warning") ->once() - ->withArgs(fn(string $msg) => Str::contains($msg, "Validation failed")); + ->withArgs(fn(string $msg): bool => Str::contains($msg, "Validation failed")); ImportHopVarietyJob::dispatchSync("hops_data/invalid.json5"); From cda96af62e91c234d6a9d28352677016138ca638 Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Tue, 3 Mar 2026 08:11:54 +0100 Subject: [PATCH 12/14] Apply suggestions --- app/Actions/UpsertHop.php | 77 ++++++++++++---------- app/Console/Commands/HopsImportCommand.php | 2 - app/Models/Hop.php | 11 ++++ 3 files changed, 53 insertions(+), 37 deletions(-) diff --git a/app/Actions/UpsertHop.php b/app/Actions/UpsertHop.php index f5e4c52..3f06f82 100644 --- a/app/Actions/UpsertHop.php +++ b/app/Actions/UpsertHop.php @@ -5,48 +5,28 @@ namespace HopsWeb\Actions; use HopsWeb\Models\Hop; +use HopsWeb\ValueObjects\RangeOrNumber; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; class UpsertHop { - private const array RANGE_FIELDS = [ - "alpha_acid", - "beta_acid", - "cohumulone", - "total_oil", - "polyphenol", - "xanthohumol", - "farnesene", - "linalool", - ]; - public function execute(array $data): Hop { - $rangeValues = $this->extractRangeValues($data); $validated = $this->validate($data); return Hop::updateOrCreate( ["name" => $validated["name"]], - $this->buildAttributes($validated, $rangeValues), + $this->buildAttributes($validated), ); } - private function extractRangeValues(array $data): array - { - $rangeValues = []; - - foreach (self::RANGE_FIELDS as $field) { - $rangeValues[$field] = $data[$field] ?? null; - } - - return $rangeValues; - } - private function validate(array $data): array { - $filteredData = array_diff_key($data, array_flip(self::RANGE_FIELDS)); + $this->validateRangeFields($data); + + $filteredData = array_diff_key($data, array_flip(Hop::RANGE_FIELDS)); $validator = Validator::make($filteredData, [ "name" => ["required", "string", "max:255"], @@ -86,10 +66,37 @@ private function validate(array $data): array throw new ValidationException($validator); } - return $validator->validated(); + $validated = $validator->validated(); + + foreach (Hop::RANGE_FIELDS as $field) { + $validated[$field] = $data[$field] ?? null; + } + + return $validated; + } + + private function validateRangeFields(array $data): void + { + $errors = []; + + foreach (Hop::RANGE_FIELDS as $field) { + $value = $data[$field] ?? null; + + if ($value === null) { + continue; + } + + if (!$value instanceof RangeOrNumber) { + $errors[$field][] = "The {$field} field must be a valid RangeOrNumber instance."; + } + } + + if (!empty($errors)) { + throw ValidationException::withMessages($errors); + } } - private function buildAttributes(array $validated, array $rangeValues): array + private function buildAttributes(array $validated): array { return [ "slug" => Str::slug($validated["name"]) . "-hop", @@ -98,14 +105,14 @@ private function buildAttributes(array $validated, array $rangeValues): array "description" => $validated["description"] ?? null, "descriptors" => $validated["descriptors"] ?? [], "lineage" => $validated["lineage"] ?? [], - "alpha_acid" => $rangeValues["alpha_acid"], - "beta_acid" => $rangeValues["beta_acid"], - "cohumulone" => $rangeValues["cohumulone"], - "total_oil" => $rangeValues["total_oil"], - "polyphenol" => $rangeValues["polyphenol"], - "xanthohumol" => $rangeValues["xanthohumol"], - "farnesene" => $rangeValues["farnesene"], - "linalool" => $rangeValues["linalool"], + "alpha_acid" => $validated["alpha_acid"], + "beta_acid" => $validated["beta_acid"], + "cohumulone" => $validated["cohumulone"], + "total_oil" => $validated["total_oil"], + "polyphenol" => $validated["polyphenol"], + "xanthohumol" => $validated["xanthohumol"], + "farnesene" => $validated["farnesene"], + "linalool" => $validated["linalool"], "thiols" => $validated["thiols"] ?? null, "aroma_citrusy" => $validated["aroma_citrusy"] ?? null, "aroma_fruity" => $validated["aroma_fruity"] ?? null, diff --git a/app/Console/Commands/HopsImportCommand.php b/app/Console/Commands/HopsImportCommand.php index 3c1ebaf..0d41f13 100644 --- a/app/Console/Commands/HopsImportCommand.php +++ b/app/Console/Commands/HopsImportCommand.php @@ -36,7 +36,5 @@ public function handle(FilesystemManager $filesystem): void $this->info("Dispatching import for: {$file}"); ImportHopVarietyJob::dispatch($file); } - - $this->info("All import jobs have been dispatched to queue, run `php artisan queue:work` to process them."); } } diff --git a/app/Models/Hop.php b/app/Models/Hop.php index 66ea830..8637285 100644 --- a/app/Models/Hop.php +++ b/app/Models/Hop.php @@ -54,6 +54,17 @@ class Hop extends Model { use HasFactory; + public const array RANGE_FIELDS = [ + "alpha_acid", + "beta_acid", + "cohumulone", + "total_oil", + "polyphenol", + "xanthohumol", + "farnesene", + "linalool", + ]; + protected $fillable = [ "name", "slug", From bb004123d395a35b47795e5319fce5494fa38a03 Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Thu, 5 Mar 2026 22:16:15 +0100 Subject: [PATCH 13/14] Add Enums --- app/Enums/HopDescriptor.php | 19 +++++++++++++++++++ app/Enums/HopLineage.php | 16 ++++++++++++++++ app/Enums/HopMaturity.php | 18 ++++++++++++++++++ app/Enums/Resistance.php | 16 ++++++++++++++++ database/factories/HopFactory.php | 20 ++++++++++++-------- 5 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 app/Enums/HopDescriptor.php create mode 100644 app/Enums/HopLineage.php create mode 100644 app/Enums/HopMaturity.php create mode 100644 app/Enums/Resistance.php diff --git a/app/Enums/HopDescriptor.php b/app/Enums/HopDescriptor.php new file mode 100644 index 0000000..be110eb --- /dev/null +++ b/app/Enums/HopDescriptor.php @@ -0,0 +1,19 @@ + $this->faker->optional()->word(), "country" => $this->faker->countryCode(), "description" => $this->faker->paragraph(), - "descriptors" => $this->faker->randomElements(["fruity", "citrusy", "herbal", "spicy", "floral", "resinous"], 2), - "lineage" => $this->faker->optional()->randomElements(["brewers-gold", "fuggle", "cascade"], 2), + "descriptors" => $this->faker->randomElements(HopDescriptor::values(), 2), + "lineage" => $this->faker->optional()->randomElements(HopLineage::values(), 2), "alpha_acid_min" => $alphaMin, "alpha_acid_max" => $alphaMin + $this->faker->randomFloat(1, 1, 5), "beta_acid_min" => $betaMin, @@ -55,7 +59,7 @@ public function definition(): array "farnesene_max" => $farneseneMin + $this->faker->randomFloat(1, 1, 5), "linalool_min" => $linaloolMin, "linalool_max" => $linaloolMin + $this->faker->randomFloat(1, 0.1, 0.5), - "thiols" => $this->faker->randomElement(["low", "medium", "high"]), + "thiols" => $this->faker->randomElement(Aromaticity::values()), "aroma_citrusy" => $this->faker->numberBetween(0, 5), "aroma_fruity" => $this->faker->numberBetween(0, 5), "aroma_floral" => $this->faker->numberBetween(0, 5), @@ -71,11 +75,11 @@ public function definition(): array ], "yield_min" => $yieldMin, "yield_max" => $yieldMin + $this->faker->numberBetween(100, 500), - "maturity" => $this->faker->optional()->randomElement(["early", "mid early", "mid late", "late", "very late"]), - "wilt_disease" => $this->faker->optional()->randomElement(["resistant", "tolerant", "susceptible"]), - "downy_mildew" => $this->faker->optional()->randomElement(["resistant", "tolerant", "susceptible"]), - "powdery_mildew" => $this->faker->optional()->randomElement(["resistant", "tolerant", "susceptible"]), - "aphid" => $this->faker->optional()->randomElement(["resistant", "tolerant", "susceptible"]), + "maturity" => $this->faker->optional()->randomElement(HopMaturity::values()), + "wilt_disease" => $this->faker->optional()->randomElement(Resistance::values()), + "downy_mildew" => $this->faker->optional()->randomElement(Resistance::values()), + "powdery_mildew" => $this->faker->optional()->randomElement(Resistance::values()), + "aphid" => $this->faker->optional()->randomElement(Resistance::values()), "bitterness" => $this->faker->optional()->randomElement(Bitterness::values()), "aromaticity" => $this->faker->optional()->randomElement(Aromaticity::values()), ]; From b852260c0465e374c39cda63df2e1ac1a5e235f2 Mon Sep 17 00:00:00 2001 From: krzysztofkozyra021 Date: Mon, 9 Mar 2026 09:49:56 +0100 Subject: [PATCH 14/14] Update Hop Model for Enums --- app/Models/Hop.php | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/app/Models/Hop.php b/app/Models/Hop.php index 8637285..af0a558 100644 --- a/app/Models/Hop.php +++ b/app/Models/Hop.php @@ -5,6 +5,12 @@ namespace HopsWeb\Models; use HopsWeb\Casts\RangeOrNumberCast; +use HopsWeb\Enums\Aromaticity; +use HopsWeb\Enums\Bitterness; +use HopsWeb\Enums\HopDescriptor; +use HopsWeb\Enums\HopLineage; +use HopsWeb\Enums\HopMaturity; +use HopsWeb\Enums\Resistance; use HopsWeb\ValueObjects\RangeOrNumber; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -17,8 +23,8 @@ * @property ?string $alt_name * @property ?string $country * @property ?string $description - * @property ?array $descriptors - * @property ?array $lineage + * @property ?array $descriptors + * @property ?array $lineage * @property ?RangeOrNumber $alpha_acid * @property ?RangeOrNumber $beta_acid * @property ?RangeOrNumber $cohumulone @@ -27,7 +33,7 @@ * @property ?RangeOrNumber $xanthohumol * @property ?RangeOrNumber $farnesene * @property ?RangeOrNumber $linalool - * @property ?string $thiols + * @property ?Aromaticity $thiols * @property ?int $aroma_citrusy * @property ?int $aroma_fruity * @property ?int $aroma_floral @@ -40,13 +46,13 @@ * @property ?array{brewhouse: array, dryhopping: array} $substitutes * @property ?int $yield_min * @property ?int $yield_max - * @property ?string $maturity - * @property ?string $wilt_disease - * @property ?string $downy_mildew - * @property ?string $powdery_mildew - * @property ?string $aphid - * @property ?string $bitterness - * @property ?string $aromaticity + * @property ?HopMaturity $maturity + * @property ?Resistance $wilt_disease + * @property ?Resistance $downy_mildew + * @property ?Resistance $powdery_mildew + * @property ?Resistance $aphid + * @property ?Bitterness $bitterness + * @property ?Aromaticity $aromaticity * @property ?Carbon $created_at * @property ?Carbon $updated_at */