From 5ab558f67dfd8a1c9007c8efc512e885517b7845 Mon Sep 17 00:00:00 2001 From: Carl Kristian Ortiz <72757862+cikeyz@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:33:23 +0800 Subject: [PATCH 1/2] feat(tests): Auth/Order/PaymentProof/Config/Prep suites + admin config CRUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 8 backend completion + coverage. Tests (51 total, all green): - AuthTest: register/login/logout, session, role assignment, me, validation. - OrderTest: pickup + delivery (shipping fee in total), shipping-zone requirement, closed-batch + capacity enforcement, status transitions write AuditLog 'status:{from}->{to}', courier-link paste writes audit, customer authz (404 on another customer's order), customer 403 on admin status update. - PaymentProofTest: owner upload -> payment_uploaded + audit, non-owner 404, invalid/missing proof 422, guest 401. - ConfigTest: public listings + admin CRUD authz for payment methods, fulfillment options, shipping zones. - PrepTest: procurement aggregation for confirmed orders, excludes cancelled, customer 403. Missing endpoints added: admin CRUD for payment methods + fulfillment options (ConfigController + routes), so Settings can manage them (UI in Step 9). Fix: OrderController date comparisons use whereDate() — the Order/Batch 'fulfillment_date' date-cast stores 'Y-m-d H:i:s' in SQLite, so plain where('fulfillment_date', 'Y-m-d') missed rows (filled=0). whereDate works in both SQLite and MySQL and keeps the prepared-statement + transaction boundaries intact. --- .../Http/Controllers/Api/ConfigController.php | 108 ++++++++ .../Http/Controllers/Api/OrderController.php | 10 +- api/routes/api.php | 10 + api/tests/Feature/AuthTest.php | 100 ++++++++ api/tests/Feature/ConfigTest.php | 95 +++++++ api/tests/Feature/OrderTest.php | 236 ++++++++++++++++++ api/tests/Feature/PaymentProofTest.php | 99 ++++++++ api/tests/Feature/PrepTest.php | 101 ++++++++ 8 files changed, 754 insertions(+), 5 deletions(-) create mode 100644 api/tests/Feature/AuthTest.php create mode 100644 api/tests/Feature/ConfigTest.php create mode 100644 api/tests/Feature/OrderTest.php create mode 100644 api/tests/Feature/PaymentProofTest.php create mode 100644 api/tests/Feature/PrepTest.php diff --git a/api/app/Http/Controllers/Api/ConfigController.php b/api/app/Http/Controllers/Api/ConfigController.php index 48cc1a8..36ce295 100644 --- a/api/app/Http/Controllers/Api/ConfigController.php +++ b/api/app/Http/Controllers/Api/ConfigController.php @@ -90,4 +90,112 @@ public function zoneDestroy(int $id): Response return $this->ok(['message' => 'Shipping tier removed.']); } + + // --- Admin: payment method CRUD (DECISIONS.md §3) --- + + public function pmIndex(): Response + { + return $this->ok(['data' => PaymentMethod::orderBy('sort_order')->get()]); + } + + public function pmStore(Request $request): Response + { + $data = $request->validate([ + 'type' => ['required', 'in:gcash,bank,cod,card'], + 'label' => ['required', 'string', 'max:60'], + 'account_name' => ['nullable', 'string', 'max:120'], + 'account_number' => ['nullable', 'string', 'max:60'], + 'qr_image_url' => ['nullable', 'string', 'url', 'max:500'], + 'is_active' => ['boolean'], + 'sort_order' => ['integer'], + ]); + + $method = PaymentMethod::create($data); + + return $this->created(['data' => $method, 'message' => 'Payment method created.']); + } + + public function pmUpdate(Request $request, int $id): Response + { + $data = $request->validate([ + 'type' => ['sometimes', 'in:gcash,bank,cod,card'], + 'label' => ['sometimes', 'string', 'max:60'], + 'account_name' => ['nullable', 'string', 'max:120'], + 'account_number' => ['nullable', 'string', 'max:60'], + 'qr_image_url' => ['nullable', 'string', 'url', 'max:500'], + 'is_active' => ['sometimes', 'boolean'], + 'sort_order' => ['sometimes', 'integer'], + ]); + + $method = PaymentMethod::find($id); + if (! $method) { + return $this->error('Payment method not found.', 404); + } + $method->update($data); + + return $this->ok(['data' => $method->fresh(), 'message' => 'Payment method updated.']); + } + + public function pmDestroy(int $id): Response + { + $method = PaymentMethod::find($id); + if (! $method) { + return $this->error('Payment method not found.', 404); + } + $method->delete(); + + return $this->ok(['message' => 'Payment method removed.']); + } + + // --- Admin: fulfillment option CRUD (DECISIONS.md §4) --- + + public function foIndex(): Response + { + return $this->ok(['data' => FulfillmentOption::orderBy('sort_order')->get()]); + } + + public function foStore(Request $request): Response + { + $data = $request->validate([ + 'mode' => ['required', 'in:pickup,lalamove,grab,pop_up_pickup'], + 'label' => ['required', 'string', 'max:60'], + 'address' => ['nullable', 'string'], + 'is_active' => ['boolean'], + 'sort_order' => ['integer'], + ]); + + $option = FulfillmentOption::create($data); + + return $this->created(['data' => $option, 'message' => 'Fulfillment option created.']); + } + + public function foUpdate(Request $request, int $id): Response + { + $data = $request->validate([ + 'mode' => ['sometimes', 'in:pickup,lalamove,grab,pop_up_pickup'], + 'label' => ['sometimes', 'string', 'max:60'], + 'address' => ['nullable', 'string'], + 'is_active' => ['sometimes', 'boolean'], + 'sort_order' => ['sometimes', 'integer'], + ]); + + $option = FulfillmentOption::find($id); + if (! $option) { + return $this->error('Fulfillment option not found.', 404); + } + $option->update($data); + + return $this->ok(['data' => $option->fresh(), 'message' => 'Fulfillment option updated.']); + } + + public function foDestroy(int $id): Response + { + $option = FulfillmentOption::find($id); + if (! $option) { + return $this->error('Fulfillment option not found.', 404); + } + $option->delete(); + + return $this->ok(['message' => 'Fulfillment option removed.']); + } } diff --git a/api/app/Http/Controllers/Api/OrderController.php b/api/app/Http/Controllers/Api/OrderController.php index c1744ce..4b94a86 100644 --- a/api/app/Http/Controllers/Api/OrderController.php +++ b/api/app/Http/Controllers/Api/OrderController.php @@ -25,13 +25,13 @@ public function batches(Request $request): Response { $date = $request->query('date', now()->toDateString()); - $batch = DB::table('batches')->where('fulfillment_date', $date)->first(); + $batch = DB::table('batches')->whereDate('fulfillment_date', $date)->first(); if (! $batch) { return $this->ok(['data' => ['date' => $date, 'capacity' => null, 'filled' => 0, 'is_open' => false]]); } $filled = DB::table('orders') - ->where('fulfillment_date', $date) + ->whereDate('fulfillment_date', $date) ->whereNotIn('status', ['cancelled']) ->count(); @@ -81,7 +81,7 @@ public function store(Request $request): Response $order = DB::transaction(function () use ($data, $user, $isDelivery) { // Atomic cutoff + capacity enforcement (race-safe via row lock). $batch = DB::table('batches') - ->where('fulfillment_date', $data['fulfillment_date']) + ->whereDate('fulfillment_date', $data['fulfillment_date']) ->lockForUpdate() ->first(); @@ -90,7 +90,7 @@ public function store(Request $request): Response } $filled = DB::table('orders') - ->where('fulfillment_date', $data['fulfillment_date']) + ->whereDate('fulfillment_date', $data['fulfillment_date']) ->whereNotIn('status', ['cancelled']) ->count(); @@ -218,7 +218,7 @@ public function adminIndex(Request $request): Response $query->where('status', $status); } if ($date = $request->query('date')) { - $query->where('fulfillment_date', $date); + $query->whereDate('fulfillment_date', $date); } $orders = $query->orderByDesc('created_at')->get(); diff --git a/api/routes/api.php b/api/routes/api.php index 53ed3d9..20b97bd 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -43,4 +43,14 @@ Route::post('/shipping-zones', [ConfigController::class, 'zoneStore']); Route::patch('/shipping-zones/{id}', [ConfigController::class, 'zoneUpdate']); Route::delete('/shipping-zones/{id}', [ConfigController::class, 'zoneDestroy']); + + Route::get('/payment-methods', [ConfigController::class, 'pmIndex']); + Route::post('/payment-methods', [ConfigController::class, 'pmStore']); + Route::patch('/payment-methods/{id}', [ConfigController::class, 'pmUpdate']); + Route::delete('/payment-methods/{id}', [ConfigController::class, 'pmDestroy']); + + Route::get('/fulfillment-options', [ConfigController::class, 'foIndex']); + Route::post('/fulfillment-options', [ConfigController::class, 'foStore']); + Route::patch('/fulfillment-options/{id}', [ConfigController::class, 'foUpdate']); + Route::delete('/fulfillment-options/{id}', [ConfigController::class, 'foDestroy']); }); diff --git a/api/tests/Feature/AuthTest.php b/api/tests/Feature/AuthTest.php new file mode 100644 index 0000000..c7372ac --- /dev/null +++ b/api/tests/Feature/AuthTest.php @@ -0,0 +1,100 @@ +postJson('/api/register', [ + 'name' => 'Juan Dela Cruz', + 'email' => 'juan@example.com', + 'phone' => '09171234567', + 'password' => 'secret123', + 'address' => 'Bacoor, Cavite', + ]); + + $response->assertStatus(201) + ->assertJsonPath('user.role', 'customer') + ->assertJsonPath('user.email', 'juan@example.com'); + $this->assertDatabaseHas('users', ['email' => 'juan@example.com', 'role' => 'customer']); + $this->assertAuthenticated(); + } + + public function test_register_validates_required_fields(): void + { + $this->postJson('/api/register', ['name' => 'X']) + ->assertStatus(422); + } + + public function test_register_rejects_duplicate_email(): void + { + User::factory()->create(['email' => 'dup@example.com']); + + $this->postJson('/api/register', [ + 'name' => 'Dup', + 'email' => 'dup@example.com', + 'password' => 'secret123', + ])->assertStatus(422); + } + + public function test_login_succeeds_with_correct_password(): void + { + User::factory()->create([ + 'email' => 'login@example.com', + 'password_hash' => bcrypt('secret123'), + ]); + + $response = $this->postJson('/api/login', [ + 'email' => 'login@example.com', + 'password' => 'secret123', + ]); + + $response->assertStatus(200) + ->assertJsonPath('user.email', 'login@example.com'); + $this->assertAuthenticated(); + } + + public function test_login_fails_with_wrong_password(): void + { + User::factory()->create([ + 'email' => 'wrong@example.com', + 'password_hash' => bcrypt('secret123'), + ]); + + $this->postJson('/api/login', [ + 'email' => 'wrong@example.com', + 'password' => 'nope-nope', + ])->assertStatus(422); + } + + public function test_logout_clears_session(): void + { + $user = User::factory()->create(); + + $this->actingAs($user)->postJson('/api/logout') + ->assertStatus(200); + + $this->assertGuest(); + } + + public function test_me_requires_auth(): void + { + $this->getJson('/api/me')->assertStatus(401); + } + + public function test_me_returns_authenticated_user(): void + { + $user = User::factory()->create(['role' => 'admin']); + + $this->actingAs($user)->getJson('/api/me') + ->assertStatus(200) + ->assertJsonPath('user.role', 'admin'); + } +} diff --git a/api/tests/Feature/ConfigTest.php b/api/tests/Feature/ConfigTest.php new file mode 100644 index 0000000..ef52d9f --- /dev/null +++ b/api/tests/Feature/ConfigTest.php @@ -0,0 +1,95 @@ + 'gcash', 'label' => 'GCash', 'is_active' => true, 'sort_order' => 1]); + PaymentMethod::create(['type' => 'cod', 'label' => 'COD', 'is_active' => true, 'sort_order' => 2]); + + $this->getJson('/api/payment-methods') + ->assertStatus(200) + ->assertJsonCount(2, 'data'); + } + + public function test_public_can_list_fulfillment_options(): void + { + FulfillmentOption::create(['mode' => 'pickup', 'label' => 'Pickup', 'is_active' => true, 'sort_order' => 1]); + + $this->getJson('/api/fulfillment-options') + ->assertStatus(200) + ->assertJsonCount(1, 'data'); + } + + public function test_public_can_list_active_shipping_zones(): void + { + ShippingZone::create(['destination' => 'Makati', 'province' => 'Metro Manila', 'fee' => 260, 'is_active' => true, 'sort_order' => 1]); + ShippingZone::create(['destination' => 'Cebu', 'province' => 'Cebu', 'fee' => 999, 'is_active' => false, 'sort_order' => 2]); + + // Only the active zone is returned to the public. + $this->getJson('/api/shipping-zones') + ->assertStatus(200) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.destination', 'Makati'); + } + + public function test_admin_can_create_payment_method(): void + { + $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->postJson('/api/admin/payment-methods', [ + 'type' => 'gcash', + 'label' => 'GCash', + 'account_number' => '09171234567', + 'is_active' => true, + ])->assertStatus(201) + ->assertJsonPath('data.type', 'gcash'); + } + + public function test_customer_cannot_create_payment_method(): void + { + $this->actingAs(User::factory()->create(['role' => 'customer'])) + ->postJson('/api/admin/payment-methods', ['type' => 'cod', 'label' => 'COD']) + ->assertStatus(403); + } + + public function test_admin_can_create_fulfillment_option(): void + { + $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->postJson('/api/admin/fulfillment-options', [ + 'mode' => 'grab', + 'label' => 'Grab', + 'is_active' => true, + ])->assertStatus(201) + ->assertJsonPath('data.mode', 'grab'); + } + + public function test_admin_can_create_shipping_zone(): void + { + $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->postJson('/api/admin/shipping-zones', [ + 'destination' => 'Taguig', + 'province' => 'Metro Manila', + 'distance_km' => 28, + 'fee' => 270, + 'is_active' => true, + ])->assertStatus(201); + $this->assertEquals(270, (float) $this->getJson('/api/admin/shipping-zones')->json('data.0.fee')); + } + + public function test_guest_cannot_access_admin_config(): void + { + $this->postJson('/api/admin/payment-methods', [])->assertStatus(401); + $this->postJson('/api/admin/shipping-zones', [])->assertStatus(401); + } +} diff --git a/api/tests/Feature/OrderTest.php b/api/tests/Feature/OrderTest.php new file mode 100644 index 0000000..86021f1 --- /dev/null +++ b/api/tests/Feature/OrderTest.php @@ -0,0 +1,236 @@ +create(['role' => 'admin']); + } + + private function customer(): User + { + return User::factory()->create(['role' => 'customer']); + } + + private function batch(string $date, int $capacity = 50, bool $open = true): void + { + DB::table('batches')->insert([ + 'fulfillment_date' => $date, + 'context' => 'online', + 'capacity' => $capacity, + 'cutoff_time' => '17:00:00', + 'is_open' => $open ? 1 : 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function menuItem(int $price = 180): int + { + return DB::table('menu_items')->insertGetId([ + 'name' => 'Pad Thai', + 'description' => null, + 'price' => $price, + 'image_url' => null, + 'is_available' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function zone(int $fee = 200): ShippingZone + { + return ShippingZone::create([ + 'destination' => 'Makati', + 'province' => 'Metro Manila', + 'distance_km' => 25, + 'fee' => $fee, + 'is_active' => true, + 'sort_order' => 1, + ]); + } + + public function test_customer_can_create_pickup_order_with_no_shipping(): void + { + $date = now()->addDay()->toDateString(); + $this->batch($date); + $menu = $this->menuItem(180); + + $response = $this->actingAs($this->customer())->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'pickup', + 'items' => [['menu_item_id' => $menu, 'quantity' => 2]], + ]); + + $response->assertStatus(201) + ->assertJsonPath('data.status', 'pending'); + $this->assertEquals(0, (float) $response->json('data.shipping_fee')); + $this->assertDatabaseHas('orders', ['shipping_fee' => 0, 'total_amount' => 360]); + } + + public function test_delivery_order_charges_zone_fee_in_total(): void + { + $date = now()->addDay()->toDateString(); + $this->batch($date); + $menu = $this->menuItem(180); + $zone = $this->zone(250); + + $response = $this->actingAs($this->customer())->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'lalamove', + 'delivery_address' => '123 Ayala Ave, Makati', + 'shipping_zone_id' => $zone->id, + 'items' => [['menu_item_id' => $menu, 'quantity' => 1]], + ]); + + $response->assertStatus(201) + ->assertJsonPath('data.status', 'pending'); + $this->assertEquals(250, (float) $response->json('data.shipping_fee')); + $this->assertEquals(430, (float) $response->json('data.total_amount')); // 180 + 250 + } + + public function test_delivery_requires_shipping_zone(): void + { + $date = now()->addDay()->toDateString(); + $this->batch($date); + $menu = $this->menuItem(); + + $this->actingAs($this->customer())->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'grab', + 'delivery_address' => 'Makati', + 'items' => [['menu_item_id' => $menu, 'quantity' => 1]], + ])->assertStatus(422); + } + + public function test_closed_batch_rejects_order(): void + { + $date = now()->addDay()->toDateString(); + $this->batch($date, 50, false); + $menu = $this->menuItem(); + + $this->actingAs($this->customer())->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'pickup', + 'items' => [['menu_item_id' => $menu, 'quantity' => 1]], + ])->assertStatus(422); + } + + public function test_capacity_reached_rejects_order(): void + { + $date = now()->addDay()->toDateString(); + $this->batch($date, 1); + $menu = $this->menuItem(); + + $this->actingAs($this->customer())->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'pickup', + 'items' => [['menu_item_id' => $menu, 'quantity' => 1]], + ])->assertStatus(201); + + // Second order exceeds capacity (capacity = 1). + $this->actingAs(User::factory()->create())->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'pickup', + 'items' => [['menu_item_id' => $menu, 'quantity' => 1]], + ])->assertStatus(422); + } + + public function test_admin_status_transition_writes_audit_log(): void + { + $date = now()->addDay()->toDateString(); + $this->batch($date); + $menu = $this->menuItem(); + + $order = $this->actingAs($this->customer())->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'pickup', + 'items' => [['menu_item_id' => $menu, 'quantity' => 1]], + ])->json('data.id'); + + $this->actingAs($this->admin())->patchJson("/api/admin/orders/{$order}/status", [ + 'status' => 'confirmed', + ])->assertStatus(200); + + $this->assertDatabaseHas('audit_logs', [ + 'order_id' => $order, + 'action' => 'status:pending->confirmed', + ]); + } + + public function test_courier_link_paste_writes_audit_log(): void + { + $date = now()->addDay()->toDateString(); + $this->batch($date); + $menu = $this->menuItem(); + $zone = $this->zone(); + + $order = $this->actingAs($this->customer())->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'lalamove', + 'delivery_address' => 'Makati', + 'shipping_zone_id' => $zone->id, + 'items' => [['menu_item_id' => $menu, 'quantity' => 1]], + ])->json('data.id'); + + $this->actingAs($this->admin())->patchJson("/api/admin/orders/{$order}/status", [ + 'status' => 'out_for_delivery', + 'courier_link' => 'https://www.lalamove.com/track/abc123', + ])->assertStatus(200); + + $this->assertDatabaseHas('orders', ['id' => $order, 'courier_link' => 'https://www.lalamove.com/track/abc123']); + $this->assertDatabaseHas('audit_logs', ['order_id' => $order, 'action' => 'status:pending->out_for_delivery']); + } + + public function test_customer_cannot_read_another_customers_order(): void + { + $date = now()->addDay()->toDateString(); + $this->batch($date); + $menu = $this->menuItem(); + + $orderId = $this->actingAs($this->customer())->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'pickup', + 'items' => [['menu_item_id' => $menu, 'quantity' => 1]], + ])->json('data.id'); + + // A different customer should get 404, not the order. + $this->actingAs(User::factory()->create())->getJson("/api/orders/{$orderId}") + ->assertStatus(404); + } + + public function test_customer_cannot_update_status(): void + { + $date = now()->addDay()->toDateString(); + $this->batch($date); + $menu = $this->menuItem(); + + $order = $this->actingAs($this->customer())->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'pickup', + 'items' => [['menu_item_id' => $menu, 'quantity' => 1]], + ])->json('data.id'); + + $this->actingAs($this->customer())->patchJson("/api/admin/orders/{$order}/status", [ + 'status' => 'confirmed', + ])->assertStatus(403); + + // No audit row should be written for the rejected attempt. + $this->assertDatabaseMissing('audit_logs', ['order_id' => $order]); + } + + public function test_guest_cannot_create_order(): void + { + $this->postJson('/api/orders', [])->assertStatus(401); + } +} diff --git a/api/tests/Feature/PaymentProofTest.php b/api/tests/Feature/PaymentProofTest.php new file mode 100644 index 0000000..814147a --- /dev/null +++ b/api/tests/Feature/PaymentProofTest.php @@ -0,0 +1,99 @@ +create(['role' => 'customer']); + } + + private function createOrder(User $user): int + { + $date = now()->addDay()->toDateString(); + DB::table('batches')->insert([ + 'fulfillment_date' => $date, + 'context' => 'online', + 'capacity' => 50, + 'cutoff_time' => '17:00:00', + 'is_open' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $menu = DB::table('menu_items')->insertGetId([ + 'name' => 'Pad Thai', + 'price' => 180, + 'is_available' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $this->actingAs($user)->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'pickup', + 'items' => [['menu_item_id' => $menu, 'quantity' => 1]], + ])->json('data.id'); + } + + public function test_owner_can_upload_proof_and_status_transitions(): void + { + Storage::fake('public'); + $user = $this->customer(); + $orderId = $this->createOrder($user); + + $response = $this->actingAs($user)->postJson("/api/orders/{$orderId}/payment-proof", [ + 'proof' => self::VALID_DATA_URL, + ]); + + $response->assertStatus(200) + ->assertJsonPath('data.status', 'payment_uploaded'); + $this->assertDatabaseHas('orders', ['id' => $orderId, 'status' => 'payment_uploaded']); + $this->assertDatabaseHas('audit_logs', ['order_id' => $orderId, 'action' => 'proof:uploaded']); + } + + public function test_non_owner_cannot_upload_proof(): void + { + $owner = $this->customer(); + $orderId = $this->createOrder($owner); + + $this->actingAs(User::factory()->create())->postJson("/api/orders/{$orderId}/payment-proof", [ + 'proof' => self::VALID_DATA_URL, + ])->assertStatus(404); + } + + public function test_rejects_invalid_data_url(): void + { + $user = $this->customer(); + $orderId = $this->createOrder($user); + + $this->actingAs($user)->postJson("/api/orders/{$orderId}/payment-proof", [ + 'proof' => 'not-a-data-url', + ])->assertStatus(422); + } + + public function test_requires_proof_field(): void + { + $user = $this->customer(); + $orderId = $this->createOrder($user); + + $this->actingAs($user)->postJson("/api/orders/{$orderId}/payment-proof", []) + ->assertStatus(422); + } + + public function test_guest_cannot_upload_proof(): void + { + $this->postJson('/api/orders/1/payment-proof', ['proof' => self::VALID_DATA_URL]) + ->assertStatus(401); + } +} diff --git a/api/tests/Feature/PrepTest.php b/api/tests/Feature/PrepTest.php new file mode 100644 index 0000000..290a337 --- /dev/null +++ b/api/tests/Feature/PrepTest.php @@ -0,0 +1,101 @@ +addDay()->toDateString(); + + DB::table('batches')->insert([ + 'fulfillment_date' => $date, + 'context' => 'online', + 'capacity' => 50, + 'cutoff_time' => '17:00:00', + 'is_open' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $menu = DB::table('menu_items')->insertGetId([ + 'name' => 'Pad Thai', + 'price' => 180, + 'is_available' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('recipe_ingredients')->insert([ + ['menu_item_id' => $menu, 'ingredient_name' => 'Rice noodles', 'quantity_per_serving' => 0.200, 'unit' => 'kg', 'created_at' => now()], + ['menu_item_id' => $menu, 'ingredient_name' => 'Shrimp', 'quantity_per_serving' => 0.100, 'unit' => 'kg', 'created_at' => now()], + ]); + + $customer = User::factory()->create(['role' => 'customer']); + $orderId = $this->actingAs($customer)->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'pickup', + 'items' => [['menu_item_id' => $menu, 'quantity' => 2]], + ])->json('data.id'); + + // Procurement only counts confirmed+ orders; advance to confirmed. + $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->patchJson("/api/admin/orders/{$orderId}/status", ['status' => 'confirmed']) + ->assertStatus(200); + + $response = $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->getJson('/api/admin/prep'); + + $response->assertStatus(200); + $noodles = collect($response->json('data'))->firstWhere('ingredient_name', 'Rice noodles'); + $this->assertNotNull($noodles); + // 0.200 per serving × 2 servings = 0.400 + $this->assertEqualsWithDelta(0.400, (float) $noodles['total_needed'], 0.001); + } + + public function test_prep_excludes_cancelled_orders(): void + { + $date = now()->addDay()->toDateString(); + DB::table('batches')->insert([ + 'fulfillment_date' => $date, 'context' => 'online', 'capacity' => 50, + 'cutoff_time' => '17:00:00', 'is_open' => 1, 'created_at' => now(), 'updated_at' => now(), + ]); + $menu = DB::table('menu_items')->insertGetId([ + 'name' => 'Curry', 'price' => 200, 'is_available' => 1, 'created_at' => now(), 'updated_at' => now(), + ]); + DB::table('recipe_ingredients')->insert([ + ['menu_item_id' => $menu, 'ingredient_name' => 'Coconut milk', 'quantity_per_serving' => 0.250, 'unit' => 'L', 'created_at' => now()], + ]); + + $customer = User::factory()->create(['role' => 'customer']); + $orderId = $this->actingAs($customer)->postJson('/api/orders', [ + 'fulfillment_date' => $date, + 'fulfillment_mode' => 'pickup', + 'items' => [['menu_item_id' => $menu, 'quantity' => 1]], + ])->json('data.id'); + + $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->patchJson("/api/admin/orders/{$orderId}/status", ['status' => 'cancelled']) + ->assertStatus(200); + + $response = $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->getJson('/api/admin/prep') + ->assertStatus(200); + + $this->assertEmpty($response->json('data')); + } + + public function test_customer_cannot_access_prep(): void + { + $this->actingAs(User::factory()->create(['role' => 'customer'])) + ->getJson('/api/admin/prep') + ->assertStatus(403); + } +} From 1120e8e5c011bc7a8416b300d5340a908697a15b Mon Sep 17 00:00:00 2001 From: Carl Kristian Ortiz <72757862+cikeyz@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:20:03 +0800 Subject: [PATCH 2/2] style: repo-wide PSR-12 pint cleanup --- api/app/Http/Controllers/Api/AuthController.php | 9 +++++---- api/app/Http/Controllers/Api/PrepController.php | 7 ++++--- api/app/Traits/JsonResponds.php | 10 ++++++---- api/bootstrap/app.php | 3 ++- .../2025_06_27_000001_create_users_table.php | 3 ++- .../2025_06_27_000002_create_menu_items_table.php | 3 ++- ...25_06_27_000003_create_recipe_ingredients_table.php | 3 ++- .../2025_06_27_000004_create_payment_methods_table.php | 3 ++- ...5_06_27_000005_create_fulfillment_options_table.php | 3 ++- .../2025_06_27_000006_create_batches_table.php | 3 ++- .../2025_06_27_000007_create_orders_table.php | 3 ++- .../2025_06_27_000008_create_order_items_table.php | 3 ++- .../2025_06_27_000009_create_settings_table.php | 3 ++- .../2025_06_27_000010_create_audit_logs_table.php | 3 ++- 14 files changed, 37 insertions(+), 22 deletions(-) diff --git a/api/app/Http/Controllers/Api/AuthController.php b/api/app/Http/Controllers/Api/AuthController.php index 4b796da..a033aa1 100644 --- a/api/app/Http/Controllers/Api/AuthController.php +++ b/api/app/Http/Controllers/Api/AuthController.php @@ -6,6 +6,7 @@ use App\Models\User; use App\Traits\JsonResponds; use Illuminate\Http\Request; +use Illuminate\Http\Response; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\ValidationException; @@ -20,7 +21,7 @@ class AuthController extends Controller { use JsonResponds; - public function register(Request $request): \Illuminate\Http\Response + public function register(Request $request): Response { $data = $request->validate([ 'name' => ['required', 'string', 'max:120'], @@ -45,7 +46,7 @@ public function register(Request $request): \Illuminate\Http\Response return $this->created(['user' => $this->userPayload($user)]); } - public function login(Request $request): \Illuminate\Http\Response + public function login(Request $request): Response { $data = $request->validate([ 'email' => ['required', 'email'], @@ -66,7 +67,7 @@ public function login(Request $request): \Illuminate\Http\Response return $this->ok(['user' => $this->userPayload($user)]); } - public function logout(Request $request): \Illuminate\Http\Response + public function logout(Request $request): Response { Auth::logout(); $request->session()->invalidate(); @@ -75,7 +76,7 @@ public function logout(Request $request): \Illuminate\Http\Response return $this->ok(['message' => 'Logged out.']); } - public function me(Request $request): \Illuminate\Http\Response + public function me(Request $request): Response { return $this->ok(['user' => $this->userPayload($request->user())]); } diff --git a/api/app/Http/Controllers/Api/PrepController.php b/api/app/Http/Controllers/Api/PrepController.php index 7c3c96a..bf0bbbe 100644 --- a/api/app/Http/Controllers/Api/PrepController.php +++ b/api/app/Http/Controllers/Api/PrepController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Traits\JsonResponds; use Illuminate\Http\Request; +use Illuminate\Http\Response; use Illuminate\Support\Facades\DB; /** @@ -15,7 +16,7 @@ class PrepController extends Controller { use JsonResponds; - public function index(Request $request): \Illuminate\Http\Response + public function index(Request $request): Response { $pdo = DB::connection()->getPdo(); @@ -29,10 +30,10 @@ public function index(Request $request): \Illuminate\Http\Response WHERE o.status IN ('confirmed', 'preparing', 'ready', 'out_for_delivery')"; if ($date = $request->query('date')) { - $stmt = $pdo->prepare($sql . " AND o.fulfillment_date = :date GROUP BY ri.ingredient_name, ri.unit, o.fulfillment_date ORDER BY ingredient_name"); + $stmt = $pdo->prepare($sql.' AND o.fulfillment_date = :date GROUP BY ri.ingredient_name, ri.unit, o.fulfillment_date ORDER BY ingredient_name'); $stmt->execute(['date' => $date]); } else { - $stmt = $pdo->prepare($sql . " GROUP BY ri.ingredient_name, ri.unit, o.fulfillment_date ORDER BY ingredient_name"); + $stmt = $pdo->prepare($sql.' GROUP BY ri.ingredient_name, ri.unit, o.fulfillment_date ORDER BY ingredient_name'); $stmt->execute(); } diff --git a/api/app/Traits/JsonResponds.php b/api/app/Traits/JsonResponds.php index 8cb45fb..42e3de6 100644 --- a/api/app/Traits/JsonResponds.php +++ b/api/app/Traits/JsonResponds.php @@ -2,6 +2,8 @@ namespace App\Traits; +use Illuminate\Http\Response; + /** * Centralised JSON responses. * @@ -10,7 +12,7 @@ */ trait JsonResponds { - protected function json(mixed $data, int $status = 200): \Illuminate\Http\Response + protected function json(mixed $data, int $status = 200): Response { return response( json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), @@ -18,17 +20,17 @@ protected function json(mixed $data, int $status = 200): \Illuminate\Http\Respon )->header('Content-Type', 'application/json; charset=utf-8'); } - protected function ok(mixed $data = []): \Illuminate\Http\Response + protected function ok(mixed $data = []): Response { return $this->json($data, 200); } - protected function created(mixed $data): \Illuminate\Http\Response + protected function created(mixed $data): Response { return $this->json($data, 201); } - protected function error(string $message, int $status, array $extra = []): \Illuminate\Http\Response + protected function error(string $message, int $status, array $extra = []): Response { return $this->json(array_merge(['message' => $message], $extra), $status); } diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index 4675472..5537267 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { $middleware->alias([ - 'role' => \App\Http\Middleware\EnsureRole::class, + 'role' => EnsureRole::class, ]); // SPA dev convenience: CSRF disabled for /api/* (session auth still enforced). diff --git a/api/database/migrations/2025_06_27_000001_create_users_table.php b/api/database/migrations/2025_06_27_000001_create_users_table.php index a0f92ce..4353f1c 100644 --- a/api/database/migrations/2025_06_27_000001_create_users_table.php +++ b/api/database/migrations/2025_06_27_000001_create_users_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('users', function (Blueprint $table) { diff --git a/api/database/migrations/2025_06_27_000002_create_menu_items_table.php b/api/database/migrations/2025_06_27_000002_create_menu_items_table.php index c123b7f..2e940d0 100644 --- a/api/database/migrations/2025_06_27_000002_create_menu_items_table.php +++ b/api/database/migrations/2025_06_27_000002_create_menu_items_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('menu_items', function (Blueprint $table) { diff --git a/api/database/migrations/2025_06_27_000003_create_recipe_ingredients_table.php b/api/database/migrations/2025_06_27_000003_create_recipe_ingredients_table.php index 1a50a7f..44183ea 100644 --- a/api/database/migrations/2025_06_27_000003_create_recipe_ingredients_table.php +++ b/api/database/migrations/2025_06_27_000003_create_recipe_ingredients_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('recipe_ingredients', function (Blueprint $table) { diff --git a/api/database/migrations/2025_06_27_000004_create_payment_methods_table.php b/api/database/migrations/2025_06_27_000004_create_payment_methods_table.php index b408fbe..0c5ce3d 100644 --- a/api/database/migrations/2025_06_27_000004_create_payment_methods_table.php +++ b/api/database/migrations/2025_06_27_000004_create_payment_methods_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('payment_methods', function (Blueprint $table) { diff --git a/api/database/migrations/2025_06_27_000005_create_fulfillment_options_table.php b/api/database/migrations/2025_06_27_000005_create_fulfillment_options_table.php index bc38f76..06b604f 100644 --- a/api/database/migrations/2025_06_27_000005_create_fulfillment_options_table.php +++ b/api/database/migrations/2025_06_27_000005_create_fulfillment_options_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('fulfillment_options', function (Blueprint $table) { diff --git a/api/database/migrations/2025_06_27_000006_create_batches_table.php b/api/database/migrations/2025_06_27_000006_create_batches_table.php index 9a13c26..3e45915 100644 --- a/api/database/migrations/2025_06_27_000006_create_batches_table.php +++ b/api/database/migrations/2025_06_27_000006_create_batches_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('batches', function (Blueprint $table) { diff --git a/api/database/migrations/2025_06_27_000007_create_orders_table.php b/api/database/migrations/2025_06_27_000007_create_orders_table.php index 804fadf..9a77dbf 100644 --- a/api/database/migrations/2025_06_27_000007_create_orders_table.php +++ b/api/database/migrations/2025_06_27_000007_create_orders_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('orders', function (Blueprint $table) { diff --git a/api/database/migrations/2025_06_27_000008_create_order_items_table.php b/api/database/migrations/2025_06_27_000008_create_order_items_table.php index 3863cc5..4ff8fae 100644 --- a/api/database/migrations/2025_06_27_000008_create_order_items_table.php +++ b/api/database/migrations/2025_06_27_000008_create_order_items_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('order_items', function (Blueprint $table) { diff --git a/api/database/migrations/2025_06_27_000009_create_settings_table.php b/api/database/migrations/2025_06_27_000009_create_settings_table.php index ef2aa61..1744134 100644 --- a/api/database/migrations/2025_06_27_000009_create_settings_table.php +++ b/api/database/migrations/2025_06_27_000009_create_settings_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('settings', function (Blueprint $table) { diff --git a/api/database/migrations/2025_06_27_000010_create_audit_logs_table.php b/api/database/migrations/2025_06_27_000010_create_audit_logs_table.php index 71f52fe..ada3ba6 100644 --- a/api/database/migrations/2025_06_27_000010_create_audit_logs_table.php +++ b/api/database/migrations/2025_06_27_000010_create_audit_logs_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('audit_logs', function (Blueprint $table) {