store/tests/Feature/Admin/Manage/PurchaseTest.php

757 lines
27 KiB
PHP

<?php
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\Permission as PermissionEnum;
use App\Models\OwnerVerificationRequest;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\RawMaterialPrice;
use App\Models\Supplier;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
});
function createPurchaseUserWithPermission(PermissionEnum ...$permissions): User
{
$user = User::factory()->create();
$user->givePermissionTo(
array_merge(
[PermissionEnum::DASHBOARD_VIEW->value],
array_map(fn (PermissionEnum $p) => $p->value, $permissions)
)
);
$user->forgetCachedPermissions();
return $user;
}
function createPurchaseVerifierUser(): User
{
$user = User::factory()->create();
$user->givePermissionTo([
PermissionEnum::DASHBOARD_VIEW->value,
PermissionEnum::OWNER_VERIFICATIONS_VIEW->value,
PermissionEnum::OWNER_VERIFICATIONS_VERIFY->value,
PermissionEnum::OWNER_VERIFICATIONS_REJECT->value,
]);
$user->forgetCachedPermissions();
return $user;
}
function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerificationRequest
{
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
test()->actingAs($verifier)
->post(route('admin.manage.owner_verifications.approve_request', $verificationRequest))
->assertRedirect();
return $verificationRequest->fresh();
}
describe('Purchase Owner Verification', function () {
test('create purchase submits verification without changing stock', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
);
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$initialStock = (float) $price->stock;
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'notes' => 'Belanja test',
])
->assertRedirect(route('admin.manage.purchases.index'));
$purchase = Purchase::query()->latest()->first();
expect($purchase)->not->toBeNull();
$this->assertDatabaseHas('owner_verification_requests', [
'subject_type' => Purchase::class,
'subject_id' => $purchase->id,
'action' => OwnerVerificationAction::CREATE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
expect((float) $price->fresh()->stock)->toBe($initialStock);
});
test('approving create purchase increments stock', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
);
$verifier = createPurchaseVerifierUser();
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
approveLatestPurchaseVerificationRequest($verifier);
expect((float) $price->fresh()->stock)->toBe(12.0);
});
test('update purchase submits verification request only', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
PermissionEnum::PURCHASES_UPDATE,
);
$verifier = createPurchaseVerifierUser();
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10, 'price' => 50_000]);
$otherPrice = RawMaterialPrice::factory()->create(['stock' => 5, 'price' => 30_000]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
approveLatestPurchaseVerificationRequest($verifier);
$purchase = Purchase::query()->latest()->firstOrFail();
$originalTotal = $purchase->total;
$this->actingAs($user)
->put(route('admin.manage.purchases.update', $purchase), [
'supplier_id' => $supplier->id,
'discount' => 1000,
'shipping_cost' => 0,
'notes' => 'Ubah belanja',
'items' => [
['raw_material_price_id' => $otherPrice->id, 'quantity' => 1],
],
])
->assertRedirect(route('admin.manage.purchases.index'));
$this->assertDatabaseHas('owner_verification_requests', [
'subject_type' => Purchase::class,
'subject_id' => $purchase->id,
'action' => OwnerVerificationAction::UPDATE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
expect($purchase->fresh()->total)->toBe($originalTotal);
});
test('delete purchase submits verification request only', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
PermissionEnum::PURCHASES_DELETE,
);
$verifier = createPurchaseVerifierUser();
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
approveLatestPurchaseVerificationRequest($verifier);
$purchase = Purchase::query()->latest()->firstOrFail();
$this->actingAs($user)
->delete(route('admin.manage.purchases.destroy', $purchase))
->assertRedirect(route('admin.manage.purchases.index'));
$this->assertDatabaseHas('owner_verification_requests', [
'subject_type' => Purchase::class,
'subject_id' => $purchase->id,
'action' => OwnerVerificationAction::DELETE->value,
'status' => OwnerVerificationStatus::PENDING->value,
]);
expect(Purchase::query()->whereKey($purchase->id)->exists())->toBeTrue();
expect((float) $price->fresh()->stock)->toBe(12.0);
});
test('rejecting create purchase deletes purchase without changing stock', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
);
$verifier = createPurchaseVerifierUser();
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
$purchase = Purchase::query()->latest()->firstOrFail();
$verificationRequest = OwnerVerificationRequest::query()->pending()->latest()->firstOrFail();
$this->actingAs($verifier)
->post(route('admin.manage.owner_verifications.reject_request', $verificationRequest), [
'reason' => 'Tidak sesuai',
])
->assertRedirect();
expect(Purchase::query()->whereKey($purchase->id)->exists())->toBeFalse();
expect(PurchaseItem::query()->where('purchase_id', $purchase->id)->exists())->toBeFalse();
expect((float) $price->fresh()->stock)->toBe(10.0);
});
test('pending purchase blocks edit route', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
PermissionEnum::PURCHASES_UPDATE,
);
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
$purchase = Purchase::query()->latest()->firstOrFail();
$this->actingAs($user)
->get(route('admin.manage.purchases.edit', $purchase))
->assertRedirect(route('admin.manage.purchases.index'));
});
test('purchase index exposes pending verification state', function () {
$user = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
);
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
$purchase = Purchase::query()->latest()->firstOrFail();
$response = $this->actingAs($user)
->get(route('admin.manage.purchases.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('purchases.data.0.id', $purchase->id)
->where('purchases.data.0.has_pending_request', true)
->where('purchases.data.0.pending_request_action', OwnerVerificationAction::CREATE->value)
);
});
});
// ─── Index ────────────────────────────────────────────────
describe('Purchase Index', function () {
test('authenticated user with permission can view purchase index', function () {
$user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW);
$this->actingAs($user)
->get(route('admin.manage.purchases.index'))
->assertOk();
});
test('guest is redirected to login', function () {
$this->get(route('admin.manage.purchases.index'))
->assertRedirect(route('login'));
});
test('user without permission is forbidden', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('admin.manage.purchases.index'))
->assertForbidden();
});
test('index can search purchases by supplier name', function () {
$user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW);
$supplier = Supplier::factory()->create(['name' => 'Supplier Kain']);
$purchase = Purchase::factory()->create(['supplier_id' => $supplier->id, 'created_by_id' => $user->id]);
$this->actingAs($user)
->get(route('admin.manage.purchases.index', ['search' => 'Kain']))
->assertOk();
});
});
// ─── Create ───────────────────────────────────────────────
describe('Purchase Create', function () {
test('authenticated user with permission can view create form', function () {
$user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE);
$this->actingAs($user)
->get(route('admin.manage.purchases.create'))
->assertOk();
});
test('guest is redirected to login', function () {
$this->get(route('admin.manage.purchases.create'))
->assertRedirect(route('login'));
});
test('user without create permission is forbidden', function () {
$user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW);
$this->actingAs($user)
->get(route('admin.manage.purchases.create'))
->assertForbidden();
});
});
// ─── Store Validation ─────────────────────────────────────
describe('Purchase Store Validation', function () {
test('supplier_id is required', function () {
$user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE);
$price = RawMaterialPrice::factory()->create();
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => '',
'discount' => 0,
'shipping_cost' => 0,
])
->assertSessionHasErrors('supplier_id');
});
test('supplier must exist', function () {
$user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE);
$price = RawMaterialPrice::factory()->create();
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => 99999,
'discount' => 0,
'shipping_cost' => 0,
])
->assertSessionHasErrors('supplier_id');
});
test('store fails without draft items', function () {
$user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE);
$supplier = Supplier::factory()->create();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertSessionHasErrors('items');
});
});
// ─── Edit ─────────────────────────────────────────────────
describe('Purchase Edit', function () {
test('authenticated user with permission can view edit form', function () {
$user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE, PermissionEnum::PURCHASES_UPDATE);
$verifier = createPurchaseVerifierUser();
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
]);
approveLatestPurchaseVerificationRequest($verifier);
$purchase = Purchase::query()->latest()->firstOrFail();
$this->actingAs($user)
->get(route('admin.manage.purchases.edit', $purchase))
->assertOk();
});
test('guest is redirected to login', function () {
$purchase = Purchase::factory()->create();
$this->get(route('admin.manage.purchases.edit', $purchase))
->assertRedirect(route('login'));
});
test('user without update permission is forbidden', function () {
$user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW);
$purchase = Purchase::factory()->create();
$this->actingAs($user)
->get(route('admin.manage.purchases.edit', $purchase))
->assertForbidden();
});
});
// ─── Update Validation ────────────────────────────────────
describe('Purchase Update Validation', function () {
test('items is required on update', function () {
$user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW, PermissionEnum::PURCHASES_CREATE, PermissionEnum::PURCHASES_UPDATE);
$verifier = createPurchaseVerifierUser();
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 2,
])
->assertOk();
$this->actingAs($user)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
]);
approveLatestPurchaseVerificationRequest($verifier);
$purchase = Purchase::query()->latest()->firstOrFail();
$this->actingAs($user)
->put(route('admin.manage.purchases.update', $purchase), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
'items' => [],
])
->assertSessionHasErrors('items');
});
});
// ─── Destroy ──────────────────────────────────────────────
describe('Purchase Destroy', function () {
test('guest cannot delete a purchase', function () {
$purchase = Purchase::factory()->create();
$this->delete(route('admin.manage.purchases.destroy', $purchase))
->assertRedirect(route('login'));
});
test('user without delete permission is forbidden', function () {
$user = createPurchaseUserWithPermission(PermissionEnum::PURCHASES_VIEW);
$purchase = Purchase::factory()->create();
$this->actingAs($user)
->delete(route('admin.manage.purchases.destroy', $purchase))
->assertForbidden();
});
});
// ─── Owner Direct Actions ──────────────────────────────────
describe('Owner Direct Actions', function () {
test('owner can create purchase directly and increments stock', function () {
$owner = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
PermissionEnum::OWNER_VERIFICATIONS_VERIFY,
);
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($owner)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 3,
])
->assertOk();
$this->actingAs($owner)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
])
->assertRedirect(route('admin.manage.purchases.index'));
$purchase = Purchase::query()->latest()->first();
expect($purchase)->not->toBeNull();
$this->assertDatabaseMissing('owner_verification_requests', [
'subject_id' => $purchase->id,
'subject_type' => Purchase::class,
]);
expect((float) $price->fresh()->stock)->toBe(13.0);
});
test('owner can delete purchase directly and decrements stock', function () {
$owner = createPurchaseUserWithPermission(
PermissionEnum::PURCHASES_VIEW,
PermissionEnum::PURCHASES_CREATE,
PermissionEnum::PURCHASES_DELETE,
PermissionEnum::OWNER_VERIFICATIONS_VERIFY,
);
$supplier = Supplier::factory()->create();
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$this->actingAs($owner)
->postJson(route('admin.manage.purchases.draft_items.store'), [
'raw_material_price_id' => $price->id,
'quantity' => 3,
])
->assertOk();
$this->actingAs($owner)
->post(route('admin.manage.purchases.store'), [
'supplier_id' => $supplier->id,
'discount' => 0,
'shipping_cost' => 0,
]);
expect((float) $price->fresh()->stock)->toBe(13.0);
$purchase = Purchase::query()->latest()->firstOrFail();
$this->actingAs($owner)
->delete(route('admin.manage.purchases.destroy', $purchase))
->assertRedirect(route('admin.manage.purchases.index'));
expect(Purchase::query()->whereKey($purchase->id)->exists())->toBeFalse();
expect((float) $price->fresh()->stock)->toBe(10.0);
});
});
// ─── Purchase Model ────────────────────────────────────────
describe('Purchase Model', function () {
test('purchase uses soft deletes', function () {
$purchase = Purchase::factory()->create();
$purchase->delete();
expect($purchase->trashed())->toBeTrue();
});
test('purchase has amounts cast to integer', function () {
$purchase = Purchase::factory()->create([
'subtotal' => 100000,
'discount' => 5000,
'shipping_cost' => 10000,
'total' => 105000,
]);
expect($purchase->subtotal)->toBeInt();
expect($purchase->discount)->toBeInt();
expect($purchase->shipping_cost)->toBeInt();
expect($purchase->total)->toBeInt();
});
test('purchase has formatted amount accessors', function () {
$purchase = Purchase::factory()->create([
'subtotal' => 100000,
'discount' => 5000,
'shipping_cost' => 10000,
'total' => 105000,
]);
expect($purchase->subtotal_formatted)->toBe('Rp 100.000');
expect($purchase->discount_formatted)->toBe('Rp 5.000');
expect($purchase->shipping_cost_formatted)->toBe('Rp 10.000');
expect($purchase->total_formatted)->toBe('Rp 105.000');
});
test('purchase belongs to supplier', function () {
$supplier = Supplier::factory()->create();
$purchase = Purchase::factory()->create(['supplier_id' => $supplier->id]);
expect($purchase->supplier)->not->toBeNull();
expect($purchase->supplier->id)->toBe($supplier->id);
});
test('purchase belongs to created by user', function () {
$user = User::factory()->create();
$purchase = Purchase::factory()->create(['created_by_id' => $user->id]);
expect($purchase->createdBy)->not->toBeNull();
expect($purchase->createdBy->id)->toBe($user->id);
});
test('purchase can have items', function () {
$purchase = Purchase::factory()->create();
PurchaseItem::factory()->count(3)->create(['purchase_id' => $purchase->id]);
expect($purchase->fresh()->items)->toHaveCount(3);
});
});
// ─── PurchaseItem Model ────────────────────────────────────
describe('PurchaseItem Model', function () {
test('purchase item has amounts cast to integer', function () {
$purchase = Purchase::factory()->create();
$item = PurchaseItem::factory()->create([
'purchase_id' => $purchase->id,
'unit_price' => 50000,
'subtotal' => 100000,
]);
expect($item->unit_price)->toBeInt();
expect($item->subtotal)->toBeInt();
});
test('purchase item has formatted amount accessors', function () {
$purchase = Purchase::factory()->create();
$item = PurchaseItem::factory()->create([
'purchase_id' => $purchase->id,
'unit_price' => 50000,
'subtotal' => 100000,
]);
expect($item->unit_price_formatted)->toBe('Rp 50.000');
expect($item->subtotal_formatted)->toBe('Rp 100.000');
});
test('purchase item belongs to purchase', function () {
$purchase = Purchase::factory()->create();
$item = PurchaseItem::factory()->create(['purchase_id' => $purchase->id]);
expect($item->purchase)->not->toBeNull();
expect($item->purchase->id)->toBe($purchase->id);
});
test('purchase item uses soft deletes', function () {
$purchase = Purchase::factory()->create();
$item = PurchaseItem::factory()->create(['purchase_id' => $purchase->id]);
$item->delete();
expect($item->trashed())->toBeTrue();
});
});