test(04-02): asset service tests — 11 cases covering full lifecycle
- Assign to subscriber/technician creates correct ISSUED movements - Return from subscriber/technician creates RETURNED with condition - Rejects reassignment without return, rejects batch items - Admin disposal creates write-off JE (DR 5030, CR 1200) - Non-admin disposal rejected, field disposal rejected - History returns chronological timeline with resolved names - Full lifecycle: RECEIVED->ISSUED->RETURNED->ISSUED->RETURNED->DISPOSED Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
557
src/lib/__tests__/asset-service.test.ts
Normal file
557
src/lib/__tests__/asset-service.test.ts
Normal file
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* Asset Service Integration Tests
|
||||
*
|
||||
* Tests the full asset lifecycle:
|
||||
* 1. Assign item to subscriber — creates ISSUED movement with SUBSCRIBER location
|
||||
* 2. Assign item to technician — creates ISSUED movement with TECHNICIAN location
|
||||
* 3. Return item from subscriber — creates RETURNED movement back to WAREHOUSE with condition
|
||||
* 4. Return item from technician — creates RETURNED movement back to WAREHOUSE
|
||||
* 5. Reject assignment of item already with a subscriber (must return first)
|
||||
* 6. Reject assignment of batch item to subscriber (only SERIALIZED allowed)
|
||||
* 7. Dispose item — ADMIN creates DISPOSED movement + write-off JE (DR 5030, CR 1200)
|
||||
* 8. Reject disposal by non-admin (OFFICE_STAFF gets authorization error)
|
||||
* 9. Reject disposal of item not in warehouse
|
||||
* 10. Get asset history — returns chronological timeline with resolved location names
|
||||
* 11. Full lifecycle: RECEIVED -> ISSUED to tech -> RETURNED -> ISSUED to sub -> RETURNED -> DISPOSED
|
||||
*
|
||||
* CLEANUP ORDER:
|
||||
* stockMovements -> inventoryItems -> journalEntryLines -> null reversesEntryId ->
|
||||
* journalEntries -> subscribers -> servicePlans -> accountingPeriods -> accounts -> users -> tenant
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||
import { seedChartOfAccounts } from "@/lib/accounting/seed-coa";
|
||||
import { InventoryService } from "@/lib/services/inventory-service";
|
||||
import { AssetService } from "@/lib/services/asset-service";
|
||||
import { Prisma, Role, TenantStatus } from "@prisma/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared test state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TS = Date.now();
|
||||
|
||||
let tenantId: string;
|
||||
let adminUserId: string;
|
||||
let officeStaffUserId: string;
|
||||
let technicianUserId: string;
|
||||
let subscriberId: string;
|
||||
let servicePlanId: string;
|
||||
|
||||
// Account IDs for JE verification
|
||||
let equipExpenseAccountId: string; // 5030
|
||||
let equipInventoryAccountId: string; // 1200
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tp() {
|
||||
return withTenantContext(tenantId);
|
||||
}
|
||||
|
||||
/** Create a serialized item and receive it into the warehouse */
|
||||
async function createAndReceiveItem(suffix: string, cost = 150) {
|
||||
const item = await InventoryService.registerItem(tp(), tenantId, {
|
||||
name: `Asset Test Item ${suffix}`,
|
||||
itemType: "ONU",
|
||||
model: "HG8145V5",
|
||||
serialNumber: `SN-ASSET-${TS}-${suffix}`,
|
||||
trackingType: "SERIALIZED",
|
||||
purchaseCost: cost,
|
||||
});
|
||||
|
||||
await InventoryService.recordMovement(tp(), tenantId, {
|
||||
inventoryItemId: item.id,
|
||||
movementType: "RECEIVED",
|
||||
condition: "NEW",
|
||||
toLocationType: "WAREHOUSE",
|
||||
toLocationId: "main-warehouse",
|
||||
performedById: adminUserId,
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup / Teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create tenant
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
name: `Asset Test Tenant ${TS}`,
|
||||
slug: `asset-test-${TS}`,
|
||||
ownerEmail: `asset-${TS}@test.example`,
|
||||
status: TenantStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
tenantId = tenant.id;
|
||||
|
||||
// Seed COA
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await seedChartOfAccounts(tx as any, tenantId);
|
||||
});
|
||||
|
||||
// Look up account IDs
|
||||
const accounts = await prisma.account.findMany({
|
||||
where: { tenantId, code: { in: ["5030", "1200"] } },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
const accountMap = new Map(accounts.map((a) => [a.code, a.id]));
|
||||
equipExpenseAccountId = accountMap.get("5030")!;
|
||||
equipInventoryAccountId = accountMap.get("1200")!;
|
||||
|
||||
expect(equipExpenseAccountId).toBeDefined();
|
||||
expect(equipInventoryAccountId).toBeDefined();
|
||||
|
||||
// Create admin user
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
email: `asset-admin-${TS}@test.example`,
|
||||
passwordHash: "hashed",
|
||||
firstName: "Asset",
|
||||
lastName: "Admin",
|
||||
tenantId,
|
||||
roles: [Role.ADMIN],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
adminUserId = admin.id;
|
||||
|
||||
// Create office staff user
|
||||
const staff = await prisma.user.create({
|
||||
data: {
|
||||
email: `asset-staff-${TS}@test.example`,
|
||||
passwordHash: "hashed",
|
||||
firstName: "Office",
|
||||
lastName: "Staff",
|
||||
tenantId,
|
||||
roles: [Role.OFFICE_STAFF],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
officeStaffUserId = staff.id;
|
||||
|
||||
// Create technician user
|
||||
const tech = await prisma.user.create({
|
||||
data: {
|
||||
email: `asset-tech-${TS}@test.example`,
|
||||
passwordHash: "hashed",
|
||||
firstName: "Field",
|
||||
lastName: "Tech",
|
||||
tenantId,
|
||||
roles: [Role.TECHNICIAN],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
technicianUserId = tech.id;
|
||||
|
||||
// Create service plan (needed for subscriber)
|
||||
const plan = await prisma.servicePlan.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: `Asset Test Plan ${TS}`,
|
||||
speed: "50 Mbps",
|
||||
monthlyPrice: new Prisma.Decimal(999),
|
||||
billingType: "PREPAID",
|
||||
},
|
||||
});
|
||||
servicePlanId = plan.id;
|
||||
|
||||
// Create subscriber
|
||||
const subscriber = await prisma.subscriber.create({
|
||||
data: {
|
||||
tenantId,
|
||||
accountNumber: `SUB-ASSET-${TS}`,
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
address: "123 Test St",
|
||||
servicePlanId,
|
||||
billingDay: 15,
|
||||
},
|
||||
});
|
||||
subscriberId = subscriber.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup in order
|
||||
await prisma.stockMovement.deleteMany({ where: { tenantId } });
|
||||
await prisma.inventoryItem.deleteMany({ where: { tenantId } });
|
||||
await prisma.journalEntryLine.deleteMany({ where: { tenantId } });
|
||||
await prisma.journalEntry.updateMany({
|
||||
where: { tenantId, reversesEntryId: { not: null } },
|
||||
data: { reversesEntryId: null },
|
||||
});
|
||||
await prisma.journalEntry.deleteMany({ where: { tenantId } });
|
||||
await prisma.subscriber.deleteMany({ where: { tenantId } });
|
||||
await prisma.servicePlan.deleteMany({ where: { tenantId } });
|
||||
await prisma.accountingPeriod.deleteMany({ where: { tenantId } });
|
||||
await prisma.account.deleteMany({ where: { tenantId } });
|
||||
await prisma.user.deleteMany({ where: { tenantId } });
|
||||
await prisma.tenant.deleteMany({ where: { id: tenantId } });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Assignment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("assignToSubscriber", () => {
|
||||
it("assigns item to subscriber — creates ISSUED movement with SUBSCRIBER location", async () => {
|
||||
const item = await createAndReceiveItem("sub-assign");
|
||||
|
||||
const movement = await AssetService.assignToSubscriber(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
subscriberId,
|
||||
condition: "NEW",
|
||||
performedById: adminUserId,
|
||||
notes: "Installation at customer site",
|
||||
});
|
||||
|
||||
expect(movement.movementType).toBe("ISSUED");
|
||||
expect(movement.fromLocationType).toBe("WAREHOUSE");
|
||||
expect(movement.toLocationType).toBe("SUBSCRIBER");
|
||||
expect(movement.toLocationId).toBe(subscriberId);
|
||||
expect(movement.condition).toBe("NEW");
|
||||
expect(movement.notes).toBe("Installation at customer site");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assignToTechnician", () => {
|
||||
it("assigns item to technician — creates ISSUED movement with TECHNICIAN location", async () => {
|
||||
const item = await createAndReceiveItem("tech-assign");
|
||||
|
||||
const movement = await AssetService.assignToTechnician(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
technicianUserId,
|
||||
condition: "NEW",
|
||||
performedById: adminUserId,
|
||||
});
|
||||
|
||||
expect(movement.movementType).toBe("ISSUED");
|
||||
expect(movement.fromLocationType).toBe("WAREHOUSE");
|
||||
expect(movement.toLocationType).toBe("TECHNICIAN");
|
||||
expect(movement.toLocationId).toBe(technicianUserId);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Return
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("returnAsset", () => {
|
||||
it("returns item from subscriber — creates RETURNED movement to WAREHOUSE with condition", async () => {
|
||||
const item = await createAndReceiveItem("sub-return");
|
||||
|
||||
// Assign to subscriber first
|
||||
await AssetService.assignToSubscriber(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
subscriberId,
|
||||
condition: "NEW",
|
||||
performedById: adminUserId,
|
||||
});
|
||||
|
||||
// Return it
|
||||
const movement = await AssetService.returnAsset(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
condition: "USED",
|
||||
performedById: adminUserId,
|
||||
notes: "Retrieved during service call",
|
||||
});
|
||||
|
||||
expect(movement.movementType).toBe("RETURNED");
|
||||
expect(movement.fromLocationType).toBe("SUBSCRIBER");
|
||||
expect(movement.fromLocationId).toBe(subscriberId);
|
||||
expect(movement.toLocationType).toBe("WAREHOUSE");
|
||||
expect(movement.condition).toBe("USED");
|
||||
});
|
||||
|
||||
it("returns item from technician — creates RETURNED movement to WAREHOUSE", async () => {
|
||||
const item = await createAndReceiveItem("tech-return");
|
||||
|
||||
// Assign to technician first
|
||||
await AssetService.assignToTechnician(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
technicianUserId,
|
||||
condition: "NEW",
|
||||
performedById: adminUserId,
|
||||
});
|
||||
|
||||
// Return it
|
||||
const movement = await AssetService.returnAsset(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
condition: "USED",
|
||||
performedById: technicianUserId,
|
||||
});
|
||||
|
||||
expect(movement.movementType).toBe("RETURNED");
|
||||
expect(movement.fromLocationType).toBe("TECHNICIAN");
|
||||
expect(movement.toLocationType).toBe("WAREHOUSE");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Rejection cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("assignment rejections", () => {
|
||||
it("rejects assignment of item already with a subscriber (must return first)", async () => {
|
||||
const item = await createAndReceiveItem("already-assigned");
|
||||
|
||||
// Assign to subscriber
|
||||
await AssetService.assignToSubscriber(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
subscriberId,
|
||||
condition: "NEW",
|
||||
performedById: adminUserId,
|
||||
});
|
||||
|
||||
// Try to assign to subscriber again
|
||||
await expect(
|
||||
AssetService.assignToSubscriber(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
subscriberId,
|
||||
condition: "NEW",
|
||||
performedById: adminUserId,
|
||||
})
|
||||
).rejects.toThrow(/already assigned to a subscriber/i);
|
||||
});
|
||||
|
||||
it("rejects assignment of batch item to subscriber (only SERIALIZED allowed)", async () => {
|
||||
const batchItem = await InventoryService.registerItem(tp(), tenantId, {
|
||||
name: `Batch Cable ${TS}`,
|
||||
itemType: "Cable",
|
||||
trackingType: "BATCH",
|
||||
purchaseCost: 5,
|
||||
});
|
||||
|
||||
// Receive batch items
|
||||
await InventoryService.recordMovement(tp(), tenantId, {
|
||||
inventoryItemId: batchItem.id,
|
||||
movementType: "RECEIVED",
|
||||
quantity: 10,
|
||||
toLocationType: "WAREHOUSE",
|
||||
toLocationId: "main-warehouse",
|
||||
performedById: adminUserId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
AssetService.assignToSubscriber(tp(), tenantId, {
|
||||
itemId: batchItem.id,
|
||||
subscriberId,
|
||||
condition: "NEW",
|
||||
performedById: adminUserId,
|
||||
})
|
||||
).rejects.toThrow(/SERIALIZED/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Disposal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("disposeAsset", () => {
|
||||
it("ADMIN disposes item — creates DISPOSED movement + write-off JE (DR 5030, CR 1200)", async () => {
|
||||
const item = await createAndReceiveItem("dispose-admin", 200);
|
||||
|
||||
const result = await AssetService.disposeAsset(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
performedById: adminUserId,
|
||||
notes: "Water damage beyond repair",
|
||||
userRoles: [Role.ADMIN],
|
||||
});
|
||||
|
||||
// Verify movement
|
||||
expect(result.movement.movementType).toBe("DISPOSED");
|
||||
expect(result.movement.fromLocationType).toBe("WAREHOUSE");
|
||||
expect(result.movement.toLocationType).toBeNull();
|
||||
|
||||
// Verify JE was created
|
||||
expect(result.journalEntryId).not.toBeNull();
|
||||
|
||||
const je = await prisma.journalEntry.findUnique({
|
||||
where: { id: result.journalEntryId! },
|
||||
include: { lines: true },
|
||||
});
|
||||
|
||||
expect(je).not.toBeNull();
|
||||
expect(je!.status).toBe("POSTED");
|
||||
expect(je!.description).toContain("Disposal write-off");
|
||||
expect(je!.description).toContain(item.serialNumber);
|
||||
|
||||
// DR 5030 Equipment Expense
|
||||
const debitLine = je!.lines.find((l) => new Prisma.Decimal(l.debit).greaterThan(0));
|
||||
expect(debitLine?.accountId).toBe(equipExpenseAccountId);
|
||||
expect(debitLine?.debit.toString()).toBe("200");
|
||||
|
||||
// CR 1200 Equipment Inventory
|
||||
const creditLine = je!.lines.find((l) => new Prisma.Decimal(l.credit).greaterThan(0));
|
||||
expect(creditLine?.accountId).toBe(equipInventoryAccountId);
|
||||
expect(creditLine?.credit.toString()).toBe("200");
|
||||
});
|
||||
|
||||
it("rejects disposal by non-admin (OFFICE_STAFF gets authorization error)", async () => {
|
||||
const item = await createAndReceiveItem("dispose-staff");
|
||||
|
||||
await expect(
|
||||
AssetService.disposeAsset(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
performedById: officeStaffUserId,
|
||||
userRoles: [Role.OFFICE_STAFF],
|
||||
})
|
||||
).rejects.toThrow(/Only ADMIN/i);
|
||||
});
|
||||
|
||||
it("rejects disposal of item not in warehouse", async () => {
|
||||
const item = await createAndReceiveItem("dispose-field");
|
||||
|
||||
// Assign to technician (out of warehouse)
|
||||
await AssetService.assignToTechnician(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
technicianUserId,
|
||||
condition: "NEW",
|
||||
performedById: adminUserId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
AssetService.disposeAsset(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
performedById: adminUserId,
|
||||
userRoles: [Role.ADMIN],
|
||||
})
|
||||
).rejects.toThrow(/WAREHOUSE/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: History
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getAssetHistory", () => {
|
||||
it("returns chronological timeline with resolved location names", async () => {
|
||||
const item = await createAndReceiveItem("history");
|
||||
|
||||
// Assign to subscriber
|
||||
await AssetService.assignToSubscriber(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
subscriberId,
|
||||
condition: "NEW",
|
||||
performedById: adminUserId,
|
||||
});
|
||||
|
||||
// Return
|
||||
await AssetService.returnAsset(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
condition: "USED",
|
||||
performedById: adminUserId,
|
||||
});
|
||||
|
||||
const history = await AssetService.getAssetHistory(tp(), item.id);
|
||||
|
||||
expect(history).toHaveLength(3); // RECEIVED, ISSUED, RETURNED
|
||||
|
||||
// First entry: RECEIVED
|
||||
expect(history[0].movementType).toBe("RECEIVED");
|
||||
expect(history[0].fromLocation).toBeNull();
|
||||
expect(history[0].toLocation?.type).toBe("WAREHOUSE");
|
||||
expect(history[0].toLocation?.name).toBe("Warehouse");
|
||||
|
||||
// Second entry: ISSUED to subscriber
|
||||
expect(history[1].movementType).toBe("ISSUED");
|
||||
expect(history[1].fromLocation?.type).toBe("WAREHOUSE");
|
||||
expect(history[1].toLocation?.type).toBe("SUBSCRIBER");
|
||||
expect(history[1].toLocation?.name).toBe("John Doe");
|
||||
|
||||
// Third entry: RETURNED
|
||||
expect(history[2].movementType).toBe("RETURNED");
|
||||
expect(history[2].fromLocation?.type).toBe("SUBSCRIBER");
|
||||
expect(history[2].toLocation?.type).toBe("WAREHOUSE");
|
||||
expect(history[2].condition).toBe("USED");
|
||||
|
||||
// Verify performedBy is resolved
|
||||
expect(history[0].performedBy).toBe("Asset Admin");
|
||||
|
||||
// Verify chronological order
|
||||
for (let i = 1; i < history.length; i++) {
|
||||
expect(new Date(history[i].date).getTime()).toBeGreaterThanOrEqual(
|
||||
new Date(history[i - 1].date).getTime()
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Full Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("full asset lifecycle", () => {
|
||||
it("RECEIVED -> ISSUED to tech -> RETURNED -> ISSUED to sub -> RETURNED -> DISPOSED", async () => {
|
||||
const item = await createAndReceiveItem("lifecycle", 300);
|
||||
|
||||
// Step 1: Already received in createAndReceiveItem
|
||||
|
||||
// Step 2: Issue to technician
|
||||
await AssetService.assignToTechnician(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
technicianUserId,
|
||||
condition: "NEW",
|
||||
performedById: adminUserId,
|
||||
});
|
||||
|
||||
// Step 3: Return from technician
|
||||
await AssetService.returnAsset(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
condition: "USED",
|
||||
performedById: technicianUserId,
|
||||
});
|
||||
|
||||
// Step 4: Issue to subscriber
|
||||
await AssetService.assignToSubscriber(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
subscriberId,
|
||||
condition: "USED",
|
||||
performedById: adminUserId,
|
||||
});
|
||||
|
||||
// Step 5: Return from subscriber
|
||||
await AssetService.returnAsset(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
condition: "DAMAGED",
|
||||
performedById: adminUserId,
|
||||
});
|
||||
|
||||
// Step 6: Dispose
|
||||
const result = await AssetService.disposeAsset(tp(), tenantId, {
|
||||
itemId: item.id,
|
||||
performedById: adminUserId,
|
||||
notes: "End of life",
|
||||
userRoles: [Role.ADMIN],
|
||||
});
|
||||
|
||||
expect(result.movement.movementType).toBe("DISPOSED");
|
||||
expect(result.journalEntryId).not.toBeNull();
|
||||
|
||||
// Verify full history shows all 6 entries
|
||||
const history = await AssetService.getAssetHistory(tp(), item.id);
|
||||
expect(history).toHaveLength(6);
|
||||
|
||||
expect(history[0].movementType).toBe("RECEIVED");
|
||||
expect(history[1].movementType).toBe("ISSUED");
|
||||
expect(history[1].toLocation?.type).toBe("TECHNICIAN");
|
||||
expect(history[1].toLocation?.name).toBe("Field Tech");
|
||||
expect(history[2].movementType).toBe("RETURNED");
|
||||
expect(history[3].movementType).toBe("ISSUED");
|
||||
expect(history[3].toLocation?.type).toBe("SUBSCRIBER");
|
||||
expect(history[3].toLocation?.name).toBe("John Doe");
|
||||
expect(history[4].movementType).toBe("RETURNED");
|
||||
expect(history[4].condition).toBe("DAMAGED");
|
||||
expect(history[5].movementType).toBe("DISPOSED");
|
||||
|
||||
// Verify disposed item has no current location
|
||||
const location = await AssetService.getCurrentLocation(tp(), item.id);
|
||||
expect(location).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user