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/app/Actions/MapHopData.php b/app/Actions/MapHopData.php new file mode 100644 index 0000000..6dda402 --- /dev/null +++ b/app/Actions/MapHopData.php @@ -0,0 +1,82 @@ + $data["name"] ?? null, + "alt_name" => $data["altName"] ?? null, + "country" => $data["country"] ?? null, + "description" => $data["origin"] ?? null, + "descriptors" => $data["descriptors"] ?? [], + "lineage" => $data["lineage"] ?? [], + "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, + "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_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), + ]; + } + + 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"] ?? []; + + return [ + "brewhouse" => $alternatives["brewhouse"] ?? [], + "dryhopping" => $alternatives["dryhopping"] ?? [], + ]; + } +} diff --git a/app/Actions/UpsertHop.php b/app/Actions/UpsertHop.php new file mode 100644 index 0000000..3f06f82 --- /dev/null +++ b/app/Actions/UpsertHop.php @@ -0,0 +1,139 @@ +validate($data); + + return Hop::updateOrCreate( + ["name" => $validated["name"]], + $this->buildAttributes($validated), + ); + } + + private function validate(array $data): array + { + $this->validateRangeFields($data); + + $filteredData = array_diff_key($data, array_flip(Hop::RANGE_FIELDS)); + + $validator = Validator::make($filteredData, [ + "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"], + "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"], + "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_misc" => ["nullable", "integer", "between:0,5"], + "aroma_descriptors" => ["nullable", "array"], + "aroma_descriptors.*" => ["string"], + "substitutes" => ["nullable", "array"], + "substitutes.brewhouse" => ["nullable", "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()) { + throw new ValidationException($validator); + } + + $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 + { + 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" => $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, + "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" => [ + "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, + ]; + } +} diff --git a/app/Console/Commands/HopsImportCommand.php b/app/Console/Commands/HopsImportCommand.php new file mode 100644 index 0000000..0d41f13 --- /dev/null +++ b/app/Console/Commands/HopsImportCommand.php @@ -0,0 +1,40 @@ +argument("folder"); + + $files = collect($filesystem->files($folder)) + ->filter(fn(string $file): bool => in_array( + pathinfo($file, PATHINFO_EXTENSION), + ["json", "json5"], + true, + )); + + if ($files->isEmpty()) { + $this->info("No files found in {$folder} directory."); + + return; + } + + $this->info("Found {$files->count()} hop variety file(s) to import."); + + foreach ($files as $file) { + $this->info("Dispatching import for: {$file}"); + ImportHopVarietyJob::dispatch($file); + } + } +} 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 @@ +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/Models/Hop.php b/app/Models/Hop.php index 8eadd6c..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; @@ -14,8 +20,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 +33,7 @@ * @property ?RangeOrNumber $xanthohumol * @property ?RangeOrNumber $farnesene * @property ?RangeOrNumber $linalool + * @property ?Aromaticity $thiols * @property ?int $aroma_citrusy * @property ?int $aroma_fruity * @property ?int $aroma_floral @@ -31,11 +41,18 @@ * @property ?int $aroma_spicy * @property ?int $aroma_resinous * @property ?int $aroma_sugarlike - * @property ?int $aroma_miscellaneous - * @property ?array $aroma_descriptors - * @property ?array $substitutes - * @property ?string $bitterness - * @property ?string $aromaticity + * @property ?int $aroma_misc + * @property ?array $aroma_descriptors + * @property ?array{brewhouse: array, dryhopping: array} $substitutes + * @property ?int $yield_min + * @property ?int $yield_max + * @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 */ @@ -43,11 +60,25 @@ 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", + "alt_name", "country", "description", + "descriptors", + "lineage", "alpha_acid", "beta_acid", "cohumulone", @@ -56,6 +87,7 @@ class Hop extends Model "xanthohumol", "farnesene", "linalool", + "thiols", "aroma_citrusy", "aroma_fruity", "aroma_floral", @@ -63,9 +95,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 +117,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/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/database/factories/HopFactory.php b/database/factories/HopFactory.php index 857668a..cda5da6 100644 --- a/database/factories/HopFactory.php +++ b/database/factories/HopFactory.php @@ -6,6 +6,10 @@ 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\Models\Hop; use Illuminate\Database\Eloquent\Factories\Factory; @@ -24,12 +28,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(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, @@ -46,6 +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(Aromaticity::values()), "aroma_citrusy" => $this->faker->numberBetween(0, 5), "aroma_fruity" => $this->faker->numberBetween(0, 5), "aroma_floral" => $this->faker->numberBetween(0, 5), @@ -53,11 +67,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(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()), ]; } } 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(); 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..14c473e --- /dev/null +++ b/tests/Feature/Console/Commands/HopsImportCommandTest.php @@ -0,0 +1,67 @@ +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") + ->assertExitCode(0); + + 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 + { + Storage::fake("local"); + Bus::fake(); + + Storage::disk("local")->put("hops_data/citra.json", "{}"); + + $this->artisan("hops:import") + ->assertExitCode(0); + + Bus::assertDispatched(ImportHopVarietyJob::class, 1); + } + + public function testItShowsMessageWhenNoFilesFound(): void + { + Storage::fake("local"); + + $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(ImportHopVarietyJob $job): bool => $job->filePath === "custom_folder/galaxy.json5"); + } +} diff --git a/tests/Feature/Jobs/ImportHopVarietyJobTest.php b/tests/Feature/Jobs/ImportHopVarietyJobTest.php new file mode 100644 index 0000000..9535dbc --- /dev/null +++ b/tests/Feature/Jobs/ImportHopVarietyJobTest.php @@ -0,0 +1,113 @@ +put("hops_data/cascade.json5", HopFixture::cascade()); + + 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(0, $hop->aroma_misc); + $this->assertEquals(["lime", "black currant"], $hop->aroma_descriptors); + $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); + $this->assertNull($hop->polyphenol); + } + + public function testItUpdatesExistingHopOnReimport(): void + { + Storage::fake("local"); + Storage::disk("local")->put("hops_data/cascade.json5", HopFixture::cascade()); + + 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): bool => 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): bool => Str::contains($msg, "Failed to parse")); + + ImportHopVarietyJob::dispatchSync("hops_data/bad.json5"); + + $this->assertDatabaseCount("hops", 0); + } + + public function testItLogsWarningForValidationFailure(): void + { + Storage::fake("local"); + Storage::disk("local")->put("hops_data/invalid.json5", '{ country: "US" }'); + + Log::shouldReceive("warning") + ->once() + ->withArgs(fn(string $msg): bool => Str::contains($msg, "Validation failed")); + + ImportHopVarietyJob::dispatchSync("hops_data/invalid.json5"); + + $this->assertDatabaseCount("hops", 0); + } + + public function testItHandlesNullIngredientRanges(): void + { + Storage::fake("local"); + Storage::disk("local")->put("hops_data/minimal.json5", HopFixture::minimal()); + + ImportHopVarietyJob::dispatchSync("hops_data/minimal.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); + } +} 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); } } diff --git a/tests/Helpers/HopFixture.php b/tests/Helpers/HopFixture.php new file mode 100644 index 0000000..99ab7b6 --- /dev/null +++ b/tests/Helpers/HopFixture.php @@ -0,0 +1,87 @@ +