1
0
Fork 0
polaris-task-force/tests/int/market.int.spec.ts
Z8MB1E 2be1081e92 test(int): fix pre-existing tickets and market spec failures
tickets: makeUser now assigns roleDocs because getStaffUserIds queries the RBAC relationship and the legacy roles select field is not synced at runtime. Both specs: teardown deletes profiles (and user-notifications in market) before users, since those relationships are NOT NULL.
2026-08-27 14:01:16 -04:00

957 lines
32 KiB
TypeScript

import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { beforeAll, afterAll, describe, expect, it } from "vitest";
import type { Asset, BankAccount, GameNpc, LockerStorage, MarketListing, User } from "@/payload-types";
import {
autoPrice,
autoQuantity,
buyListing,
countCleanQuantity,
creditLockerQuantity,
deductLockerQuantity,
expireListing,
isEntryClean,
isNegotiable,
listingTotal,
negotiationRange,
} from "@/lib/market";
import { npcDialogue, npcResponseToOffer, NPC_PATIENCE } from "@/lib/market/negotiations";
import {
applyNpcPriceModifier,
createGeneratedNpc,
randomNpcName,
resolveVendorNpc,
} from "@/lib/market/npcs";
import { ensureLockerStorage, resolveAssetId } from "@/lib/locker";
import { applyTransaction, createAccount, getMainCurrencyId } from "@/lib/banking";
import { notifyUser } from "@/lib/notifications";
let payload: Payload;
const RUN = `mt-${Date.now().toString(36)}`;
describe("Marketplace", () => {
let assetId: number;
let seller: User;
let buyer: User;
let sellerLocker: LockerStorage;
let buyerLocker: LockerStorage;
let sellerAccount: BankAccount;
let buyerAccount: BankAccount;
let currencyId: number | null;
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
// Always create a fresh, live, tradeable asset for the test. The
// "approved" approvalStatus can't be set from an unauthenticated payload
// context (filterOptions restrict non-developers), so use the default.
const created = (await payload.create({
collection: "assets",
data: {
name: `${RUN} Asset`,
className: "test",
assetType: "miscellaneous",
approvalStatus: "in_progress",
tradeable: true,
isLive: true,
marketTrading: { Rarity: "common", perceivedValue: { baseBuyPrice: 100, baseSellPrice: 80 } },
crafting: { craftable: false, craftingData: { craftingTimePerUnit: 1, batchSize: 1 } },
storageDimensions: { gridWidth: 1, gridHeight: 1 },
},
overrideAccess: true,
depth: 0,
})) as unknown as Asset;
assetId = created.id;
const makeUser = async (label: string): Promise<User> => {
const tag = `${RUN}-${label}`;
return (await payload.create({
collection: "users",
data: {
username: tag,
discordUsername: tag,
displayName: label.toUpperCase(),
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
};
seller = await makeUser("seller");
buyer = await makeUser("buyer");
sellerLocker = await ensureLockerStorage(payload, seller.id);
buyerLocker = await ensureLockerStorage(payload, buyer.id);
// Seed the seller's locker with 5 clean units of the asset.
sellerLocker = (await payload.update({
collection: "locker-storages",
id: sellerLocker.id,
data: {
items: [
{ asset: assetId, quantity: 5, gridX: 0, gridY: 0, rotated: false },
],
},
overrideAccess: true,
depth: 1,
})) as unknown as LockerStorage;
currencyId = await getMainCurrencyId(payload);
if (!currencyId) throw new Error("Test requires a main currency in Game Rules.");
sellerAccount = await createAccount(payload, {
name: `${RUN} Seller Account`,
accountType: "personal",
ownerUserId: seller.id,
});
buyerAccount = await createAccount(payload, {
name: `${RUN} Buyer Account`,
accountType: "personal",
ownerUserId: buyer.id,
});
// Fund the buyer.
await applyTransaction(payload, {
type: "deposit",
toAccountId: buyerAccount.id,
amount: 10000,
memo: `${RUN} test funding`,
});
});
afterAll(async () => {
if (!payload) return;
const listings = await payload.find({
collection: "market-listings",
where: { listedAt: { greater_than: new Date(Date.now() - 3_600_000).toISOString() } },
limit: 200,
depth: 0,
overrideAccess: true,
});
const listingIds = listings.docs.map((l) => l.id);
// Delete referencing negotiations first — Payload nulls out the listing FK
// on listing delete, which violates the NOT NULL constraint.
if (listingIds.length > 0) {
const negotiations = await payload.find({
collection: "market-negotiations",
where: { listing: { in: listingIds } },
limit: 200,
depth: 0,
overrideAccess: true,
});
for (const negotiation of negotiations.docs) {
await payload.delete({ collection: "market-negotiations", id: negotiation.id, overrideAccess: true });
}
}
for (const listing of listings.docs) {
await payload.delete({ collection: "market-listings", id: listing.id, overrideAccess: true });
}
for (const user of [seller, buyer]) {
if (!user) continue;
const accounts = await payload.find({
collection: "bank-accounts",
where: { ownerUser: { equals: user.id } },
limit: 20,
depth: 0,
overrideAccess: true,
});
for (const account of accounts.docs) {
const txns = await payload.find({
collection: "bank-transactions",
where: { or: [{ fromAccount: { equals: account.id } }, { toAccount: { equals: account.id } }] },
limit: 50,
depth: 0,
overrideAccess: true,
});
for (const txn of txns.docs) {
const entries = await payload.find({
collection: "ledger-entries",
where: { transaction: { equals: txn.id } },
limit: 50,
depth: 0,
overrideAccess: true,
});
for (const entry of entries.docs) {
await payload.delete({ collection: "ledger-entries", id: entry.id, overrideAccess: true });
}
await payload.delete({ collection: "bank-transactions", id: txn.id, overrideAccess: true });
}
await payload.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true });
}
const lockers = await payload.find({
collection: "locker-storages",
where: { ownerUser: { equals: user.id } },
limit: 5,
depth: 0,
overrideAccess: true,
});
for (const locker of lockers.docs) {
await payload.delete({ collection: "locker-storages", id: locker.id, overrideAccess: true });
}
// profiles.user and user-notifications.user are NOT NULL — delete them before the user.
const profiles = await payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
limit: 5,
depth: 0,
overrideAccess: true,
});
for (const profile of profiles.docs) {
await payload.delete({ collection: "profiles", id: profile.id, overrideAccess: true });
}
const notifications = await payload.find({
collection: "user-notifications",
where: { user: { equals: user.id } },
limit: 100,
depth: 0,
overrideAccess: true,
});
for (const notification of notifications.docs) {
await payload.delete({ collection: "user-notifications", id: notification.id, overrideAccess: true });
}
await payload.delete({ collection: "users", id: user.id, overrideAccess: true });
}
// Delete the asset created for this test.
if (assetId) {
await payload.delete({ collection: "assets", id: assetId, overrideAccess: true });
}
// Delete any NPCs created for this test (generated + GM vendor).
const npcs = await payload.find({
collection: "game-npcs",
where: { "vendor.sells": { equals: assetId } },
limit: 50,
depth: 0,
overrideAccess: true,
});
for (const npc of npcs.docs) {
await payload.delete({ collection: "game-npcs", id: npc.id, overrideAccess: true });
}
});
it("isEntryClean treats equipment-bearing entries as unsellable", () => {
expect(
isEntryClean({ asset: assetId, quantity: 1, gridX: 0, gridY: 0 }),
).toBe(true);
expect(
isEntryClean({
asset: assetId,
quantity: 1,
gridX: 0,
gridY: 0,
equipment: { attachments: [{ asset: assetId }], skin: null },
}),
).toBe(false);
expect(
isEntryClean({
asset: assetId,
quantity: 1,
gridX: 0,
gridY: 0,
equipment: { skin: assetId },
}),
).toBe(false);
});
it("countCleanQuantity and deductLockerQuantity only consume clean stock", async () => {
const locker = (await payload.findByID({
collection: "locker-storages",
id: sellerLocker.id,
depth: 1,
overrideAccess: true,
})) as unknown as LockerStorage;
expect(countCleanQuantity(locker, assetId)).toBe(5);
const items = deductLockerQuantity(locker, assetId, 5);
expect(items.reduce((sum, e) => sum + (e.quantity ?? 0), 0)).toBe(0);
expect(() => deductLockerQuantity(locker, assetId, 6)).toThrow();
});
it("buys a listing: funds move, buyer's locker is credited, listing is sold", async () => {
// List 2 units.
const sellerLockerFull = (await payload.findByID({
collection: "locker-storages",
id: sellerLocker.id,
depth: 1,
overrideAccess: true,
})) as unknown as LockerStorage;
const afterDeduct = deductLockerQuantity(sellerLockerFull, assetId, 2);
await payload.update({
collection: "locker-storages",
id: sellerLocker.id,
data: { items: afterDeduct },
overrideAccess: true,
depth: 0,
});
const listing = (await payload.create({
collection: "market-listings",
data: {
asset: assetId,
seller: seller.id,
quantity: 2,
price: 100,
currency: currencyId,
status: "active",
listedAt: new Date().toISOString(),
},
overrideAccess: true,
depth: 0,
})) as unknown as MarketListing;
expect(listingTotal(listing)).toBe(200);
const populated = (await payload.findByID({
collection: "market-listings",
id: listing.id,
depth: 1,
overrideAccess: true,
})) as unknown as MarketListing;
const buyerBefore = (
(await payload.findByID({
collection: "bank-accounts",
id: buyerAccount.id,
depth: 0,
overrideAccess: true,
})) as unknown as BankAccount
).balance ?? 0;
const sellerBefore = (
(await payload.findByID({
collection: "bank-accounts",
id: sellerAccount.id,
depth: 0,
overrideAccess: true,
})) as unknown as BankAccount
).balance ?? 0;
const result = await buyListing(payload, populated, buyer);
expect(result.listing.status).toBe("sold");
expect(result.total).toBe(200);
const buyerAccountAfter = (await payload.findByID({
collection: "bank-accounts",
id: buyerAccount.id,
depth: 0,
overrideAccess: true,
})) as unknown as BankAccount;
const sellerAccountAfter = (await payload.findByID({
collection: "bank-accounts",
id: sellerAccount.id,
depth: 0,
overrideAccess: true,
})) as unknown as BankAccount;
expect(buyerAccountAfter.balance).toBe(buyerBefore - 200);
expect(sellerAccountAfter.balance).toBe(sellerBefore + 200);
const buyerLockerAfter = (await payload.findByID({
collection: "locker-storages",
id: buyerLocker.id,
depth: 1,
overrideAccess: true,
})) as unknown as LockerStorage;
expect(countCleanQuantity(buyerLockerAfter, assetId)).toBe(2);
});
it("cannot buy a sold/expired listing or your own listing", async () => {
const sold = (await payload.find({
collection: "market-listings",
where: { status: { equals: "sold" } },
limit: 1,
depth: 1,
overrideAccess: true,
})) as unknown as { docs: MarketListing[] };
if (sold.docs.length > 0) {
await expect(buyListing(payload, sold.docs[0], buyer)).rejects.toThrow(/no longer active/);
}
// Own listing: re-create one owned by buyer, expect rejection.
const ownListing = (await payload.create({
collection: "market-listings",
data: {
asset: assetId,
seller: buyer.id,
quantity: 1,
price: 10,
currency: currencyId,
status: "active",
listedAt: new Date().toISOString(),
},
overrideAccess: true,
depth: 0,
})) as unknown as MarketListing;
const ownPopulated = (await payload.findByID({
collection: "market-listings",
id: ownListing.id,
depth: 1,
overrideAccess: true,
})) as unknown as MarketListing;
await expect(buyListing(payload, ownPopulated, buyer)).rejects.toThrow(/own listing/);
});
it("returns stock to the seller when a listing expires or is cancelled", async () => {
const sellerLockerFull = (await payload.findByID({
collection: "locker-storages",
id: sellerLocker.id,
depth: 1,
overrideAccess: true,
})) as unknown as LockerStorage;
const before = countCleanQuantity(sellerLockerFull, assetId);
const listing = (await payload.create({
collection: "market-listings",
data: {
asset: assetId,
seller: seller.id,
quantity: 1,
price: 50,
currency: currencyId,
status: "active",
listedAt: new Date().toISOString(),
},
overrideAccess: true,
depth: 0,
})) as unknown as MarketListing;
const populated = (await payload.findByID({
collection: "market-listings",
id: listing.id,
depth: 1,
overrideAccess: true,
})) as unknown as MarketListing;
const expired = await expireListing(payload, populated);
expect(expired.status).toBe("expired");
const after = (await payload.findByID({
collection: "locker-storages",
id: sellerLocker.id,
depth: 1,
overrideAccess: true,
})) as unknown as LockerStorage;
expect(countCleanQuantity(after, assetId)).toBe(before + 1);
});
it("autoPrice/autoQuantity produce sane vendor entries", async () => {
const asset = (await payload.findByID({
collection: "assets",
id: assetId,
depth: 0,
overrideAccess: true,
})) as unknown as Asset;
const price = autoPrice(asset, "buy");
expect(price).not.toBeNull();
expect(price as number).toBeGreaterThan(0);
expect(autoQuantity(asset)).toBeGreaterThan(0);
});
it("resolveAssetId works on id and populated refs", () => {
expect(resolveAssetId(assetId)).toBe(assetId);
expect(resolveAssetId({ id: assetId } as Asset)).toBe(assetId);
});
it("negotiationRange / isNegotiable resolve min, desired, and negotiability", () => {
expect(negotiationRange({ price: 100, desiredPrice: 100, minPrice: 60 })).toEqual({ min: 60, desired: 100 });
expect(negotiationRange({ price: 100 })).toEqual({ min: 100, desired: 100 });
expect(negotiationRange({ price: 100, desiredPrice: 90, minPrice: 80 })).toEqual({ min: 80, desired: 90 });
expect(isNegotiable({ price: 100, desiredPrice: 100, minPrice: 60 })).toBe(true);
expect(isNegotiable({ price: 100 })).toBe(false);
expect(isNegotiable({ price: 100, desiredPrice: 100, minPrice: 100 })).toBe(false);
});
it("npcResponseToOffer accepts at/above desired, counters toward desired", () => {
const listing = { price: 100, desiredPrice: 100, minPrice: 60 };
expect(npcResponseToOffer(listing, 100)).toEqual({ action: "accept", amount: 100, reason: "good" });
expect(npcResponseToOffer(listing, 120)).toEqual({ action: "accept", amount: 100, reason: "overpay" });
expect(npcResponseToOffer(listing, 60).action).toBe("counter");
const counter = npcResponseToOffer(listing, 80);
expect(counter.action).toBe("counter");
if (counter.action === "counter") {
expect(counter.amount).toBeGreaterThan(80);
expect(counter.amount).toBeLessThan(100);
}
const near = npcResponseToOffer(listing, 95);
expect(["accept", "counter"]).toContain(near.action);
if (near.action === "counter") {
expect(near.amount).toBeGreaterThan(95);
}
});
it("lowball offers burn the vendor's patience fast and eventually close", () => {
const listing = { price: 5000, desiredPrice: 5000, minPrice: 3000 };
const first = npcResponseToOffer(listing, 1);
expect(first.action).toBe("counter");
if (first.action === "counter") {
expect(first.closed).toBe(false);
expect(first.patience).toBe(NPC_PATIENCE.roundCost + NPC_PATIENCE.lowballPenalty);
}
const second = npcResponseToOffer(listing, 1, { patience: first.action === "counter" ? first.patience : 0 });
expect(second.action).toBe("counter");
if (second.action === "counter") expect(second.closed).toBe(false);
const third = npcResponseToOffer(listing, 1, { patience: second.action === "counter" ? second.patience : 0 });
expect(third.action).toBe("counter");
if (third.action === "counter") expect(third.closed).toBe(true);
});
it("reasonable offers cost a little patience; near-asking offers relieve it", () => {
const listing = { price: 100, desiredPrice: 100, minPrice: 60 };
const mid = npcResponseToOffer(listing, 80);
if (mid.action === "counter") expect(mid.patience).toBe(NPC_PATIENCE.roundCost);
const good = npcResponseToOffer(listing, 90);
if (good.action === "counter") expect(good.patience).toBe(0);
const bad = npcResponseToOffer(listing, 50, { patience: 50 });
if (bad.action === "counter") expect(bad.patience).toBe(50 + NPC_PATIENCE.roundCost + NPC_PATIENCE.lowballPenalty);
});
it("bargaining in the zone eventually exhausts the vendor (finite rounds)", () => {
const listing = { price: 100, desiredPrice: 100, minPrice: 60 };
let patience = 0;
let closed = false;
let rounds = 0;
for (let i = 0; i < 20; i++) {
const response = npcResponseToOffer(listing, 80, { patience });
if (response.action === "counter") {
patience = response.patience;
closed = response.closed;
rounds++;
if (closed) break;
}
}
expect(closed).toBe(true);
expect(rounds).toBe(Math.ceil(NPC_PATIENCE.cap / NPC_PATIENCE.roundCost));
});
it("vendor counters are monotonic: never rise past the last counter", () => {
const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 };
const r1 = npcResponseToOffer(listing, 3500, { patience: 0, lastCounter: null, lastBuyerOffer: null });
expect(r1.action).toBe("counter");
const c1 = r1.action === "counter" ? r1.amount : 0;
expect(c1).toBeGreaterThan(3500);
expect(c1).toBeLessThan(4300);
const r2 = npcResponseToOffer(listing, 3800, {
patience: r1.action === "counter" ? r1.patience : 0,
lastCounter: c1,
lastBuyerOffer: 3500,
});
expect(r2.action).toBe("counter");
if (r2.action === "counter") {
expect(r2.amount).toBeLessThanOrEqual(c1);
expect(r2.amount).toBeGreaterThan(3800);
}
});
it("counter responses carry a reason and npcDialogue produces vendor lines", () => {
const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 };
const concede = npcResponseToOffer(listing, 3500, { patience: 0, lastCounter: null, lastBuyerOffer: null });
expect(concede.action).toBe("counter");
if (concede.action === "counter") {
expect(concede.reason).toBe("concede");
expect(npcDialogue(concede.reason, concede.amount)).toContain(concede.amount.toLocaleString());
}
const backward = npcResponseToOffer(listing, 3200, { patience: 12, lastCounter: 4060, lastBuyerOffer: 3800 });
expect(backward.action).toBe("counter");
if (backward.action === "counter") {
expect(backward.reason).toBe("backward");
expect(backward.amount).toBe(4060);
}
const lowball = npcResponseToOffer(listing, 1000, { patience: 0, lastCounter: null, lastBuyerOffer: null });
expect(lowball.action).toBe("counter");
if (lowball.action === "counter") expect(lowball.reason).toBe("lowball");
const firm = npcResponseToOffer(
listing,
4300 - Math.round(4300 * 0.02),
{ patience: 0, lastCounter: null, lastBuyerOffer: null },
() => 0.99,
);
expect(firm.action).toBe("counter");
if (firm.action === "counter") expect(firm.reason).toBe("firm");
expect(npcDialogue("accept", undefined, undefined, () => 0)).toBe(
"I think that price is acceptable. Let's shake on it.",
);
expect(npcDialogue("closed", undefined, 4300)).toContain("4,300");
});
it("vendor counters never drop below the min acceptable price", () => {
const listing = { price: 5000, desiredPrice: 5000, minPrice: 4100 };
const r1 = npcResponseToOffer(listing, 1000, { patience: 0, lastCounter: null, lastBuyerOffer: null });
expect(r1.action).toBe("counter");
if (r1.action === "counter") {
expect(r1.amount).toBe(4100);
expect(r1.reason).toBe("lowball");
}
// Even after more bargaining, the vendor holds the floor.
const r2 = npcResponseToOffer(listing, 3000, {
patience: r1.action === "counter" ? r1.patience : 0,
lastCounter: r1.action === "counter" ? r1.amount : null,
lastBuyerOffer: 1000,
});
expect(r2.action).toBe("counter");
if (r2.action === "counter") expect(r2.amount).toBe(4100);
});
it("tiny offer increases are stalls: flagged as such and burn the vendor's patience", () => {
const listing = { price: 5000, desiredPrice: 5000, minPrice: 3000 };
// First offer of 3500 (above the floor) draws a normal concession.
const first = npcResponseToOffer(listing, 3500, { patience: 0, lastCounter: null, lastBuyerOffer: null });
expect(first.action).toBe("counter");
if (first.action === "counter") {
expect(first.reason).toBe("concede");
expect(first.patience).toBe(NPC_PATIENCE.roundCost);
}
// Bumping by 1 (< 1% of the asking price) is a stall: big patience hit.
const second = npcResponseToOffer(listing, 3501, {
patience: first.action === "counter" ? first.patience : 0,
lastCounter: first.action === "counter" ? first.amount : null,
lastBuyerOffer: 3500,
});
expect(second.action).toBe("counter");
if (second.action === "counter") {
expect(second.reason).toBe("stall");
expect(second.patience).toBe(
NPC_PATIENCE.roundCost + NPC_PATIENCE.roundCost + NPC_PATIENCE.stallPenalty,
);
}
expect(npcDialogue("stall", undefined, undefined, () => 0)).toBe(
"Really? That's hardly different. Negotiate like you mean it.",
);
});
it("meaningful offer increases are not stalls", () => {
const listing = { price: 5000, desiredPrice: 5000, minPrice: 4100 };
const r = npcResponseToOffer(listing, 4200, {
patience: 12,
lastCounter: 4550,
lastBuyerOffer: 3500,
});
expect(r.action).toBe("counter");
if (r.action === "counter") {
expect(r.reason).toBe("concede");
expect(r.patience).toBe(12 + NPC_PATIENCE.roundCost);
}
});
it("the vendor holds firm when the buyer moves backward", () => {
const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 };
const c1 = 4060; // the vendor's counter after the buyer offered 3500
const r = npcResponseToOffer(listing, 3000, { patience: 12, lastCounter: c1, lastBuyerOffer: 3500 });
expect(r.action).toBe("counter");
if (r.action === "counter") expect(r.amount).toBe(c1);
});
it("accepts an offer at or above the vendor's stance regardless of the roll", () => {
const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 };
const state = { patience: 12, lastCounter: 3900, lastBuyerOffer: 3500 };
expect(npcResponseToOffer(listing, 3900, state, () => 0.99).action).toBe("accept");
expect(npcResponseToOffer(listing, 4000, state, () => 0.99).action).toBe("accept");
});
it("accepts with high chance when the offer is within margin of the stance, else holds", () => {
const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 };
const state = { patience: 12, lastCounter: 3900, lastBuyerOffer: 3500 };
// 2% of 3900 = 78 → threshold 3822; 3850 is inside the margin.
const yes = npcResponseToOffer(listing, 3850, state, () => 0);
expect(yes.action).toBe("accept");
if (yes.action === "accept") expect(yes.amount).toBe(3850);
const no = npcResponseToOffer(listing, 3850, state, () => 0.99);
expect(no.action).toBe("counter");
if (no.action === "counter") expect(no.amount).toBe(3900);
});
it("buys at a negotiated unit price when one is supplied", async () => {
const sellerLockerFull = (await payload.findByID({
collection: "locker-storages",
id: sellerLocker.id,
depth: 1,
overrideAccess: true,
})) as unknown as LockerStorage;
const afterDeduct = deductLockerQuantity(sellerLockerFull, assetId, 1);
await payload.update({
collection: "locker-storages",
id: sellerLocker.id,
data: { items: afterDeduct },
overrideAccess: true,
depth: 0,
});
const listing = (await payload.create({
collection: "market-listings",
data: {
asset: assetId,
seller: seller.id,
quantity: 2,
price: 100,
desiredPrice: 100,
minPrice: 60,
currency: currencyId,
status: "active",
listedAt: new Date().toISOString(),
},
overrideAccess: true,
depth: 0,
})) as unknown as MarketListing;
const populated = (await payload.findByID({
collection: "market-listings",
id: listing.id,
depth: 1,
overrideAccess: true,
})) as unknown as MarketListing;
const buyerBefore = (
(await payload.findByID({
collection: "bank-accounts",
id: buyerAccount.id,
depth: 0,
overrideAccess: true,
})) as unknown as BankAccount
).balance ?? 0;
const sellerBefore = (
(await payload.findByID({
collection: "bank-accounts",
id: sellerAccount.id,
depth: 0,
overrideAccess: true,
})) as unknown as BankAccount
).balance ?? 0;
const result = await buyListing(payload, populated, buyer, { unitPrice: 80 });
expect(result.listing.status).toBe("sold");
expect(result.total).toBe(160);
expect(result.unitPrice).toBe(80);
const buyerAfter = (await payload.findByID({
collection: "bank-accounts",
id: buyerAccount.id,
depth: 0,
overrideAccess: true,
})) as unknown as BankAccount;
const sellerAfter = (await payload.findByID({
collection: "bank-accounts",
id: sellerAccount.id,
depth: 0,
overrideAccess: true,
})) as unknown as BankAccount;
expect(buyerAfter.balance).toBe(buyerBefore - 160);
expect(sellerAfter.balance).toBe(sellerBefore + 160);
const buyerLockerAfter = (await payload.findByID({
collection: "locker-storages",
id: buyerLocker.id,
depth: 1,
overrideAccess: true,
})) as unknown as LockerStorage;
expect(countCleanQuantity(buyerLockerAfter, assetId)).toBeGreaterThanOrEqual(2);
});
it("creates in-app notifications for a user", async () => {
await notifyUser(payload, {
userId: buyer.id,
type: "market:offer",
title: "Test notification",
message: "A seller replied to your offer.",
link: "/logistics/market",
});
const found = await payload.find({
collection: "user-notifications",
where: { and: [{ user: { equals: buyer.id } }, { type: { equals: "market:offer" } }] },
limit: 5,
depth: 0,
overrideAccess: true,
});
const doc = found.docs.find((n) => n.title === "Test notification");
expect(doc).toBeDefined();
expect(doc?.read).toBe(false);
expect(doc?.link).toBe("/logistics/market");
if (doc?.id) {
await payload.delete({ collection: "user-notifications", id: doc.id, overrideAccess: true });
}
});
it("assigns auto stock to NPCs and prefers GM-assigned vendors", async () => {
const generated = await createGeneratedNpc(payload, { assetId });
expect(generated.isGenerated).toBe(true);
expect(generated.isInGame).toBe(false);
expect(generated.vendor?.enabled).toBe(true);
expect((generated.vendor?.sells ?? []).map(resolveAssetId)).toContain(assetId);
expect(generated.name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/);
expect(randomNpcName()).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/);
const vendor = (await payload.create({
collection: "game-npcs",
data: {
name: "GM Vendor",
occupation: "Weapons Dealer",
isGenerated: false,
isInGame: true,
vendor: { enabled: true, sells: [assetId] },
},
overrideAccess: true,
depth: 0,
})) as unknown as GameNpc;
const picked = await resolveVendorNpc(payload, assetId);
expect(picked.id).toBe(vendor.id);
expect(applyNpcPriceModifier(100, vendor)).toBe(100);
expect(
applyNpcPriceModifier(100, {
...vendor,
vendor: { enabled: true, priceModifier: 0.5, sells: [assetId] },
} as GameNpc),
).toBe(50);
});
it("buys a partial quantity and keeps the listing active", async () => {
const listing = (await payload.create({
collection: "market-listings",
data: {
asset: assetId,
seller: seller.id,
quantity: 3,
price: 100,
desiredPrice: 100,
minPrice: 60,
currency: currencyId,
status: "active",
listedAt: new Date().toISOString(),
},
overrideAccess: true,
depth: 0,
})) as unknown as MarketListing;
const populated = (await payload.findByID({
collection: "market-listings",
id: listing.id,
depth: 1,
overrideAccess: true,
})) as unknown as MarketListing;
const buyerBefore = (
(await payload.findByID({
collection: "bank-accounts",
id: buyerAccount.id,
depth: 0,
overrideAccess: true,
})) as unknown as BankAccount
).balance ?? 0;
const result = await buyListing(payload, populated, buyer, { quantity: 1 });
expect(result.quantity).toBe(1);
expect(result.total).toBe(100);
expect(result.unitPrice).toBe(100);
expect(result.listing.status).toBe("active");
expect(result.listing.quantity).toBe(2);
const buyerAfter = (await payload.findByID({
collection: "bank-accounts",
id: buyerAccount.id,
depth: 0,
overrideAccess: true,
})) as unknown as BankAccount;
expect(buyerAfter.balance).toBe(buyerBefore - 100);
const buyerLockerAfter = (await payload.findByID({
collection: "locker-storages",
id: buyerLocker.id,
depth: 1,
overrideAccess: true,
})) as unknown as LockerStorage;
expect(countCleanQuantity(buyerLockerAfter, assetId)).toBeGreaterThanOrEqual(1);
// Buying the remaining stock marks the listing sold.
const reFetched = (await payload.findByID({
collection: "market-listings",
id: listing.id,
depth: 1,
overrideAccess: true,
})) as unknown as MarketListing;
expect(reFetched.quantity).toBe(2);
const sold = await buyListing(payload, reFetched, buyer);
expect(sold.quantity).toBe(2);
expect(sold.total).toBe(200);
expect(sold.listing.status).toBe("sold");
});
it("rejects invalid buy quantities", async () => {
const listing = (await payload.create({
collection: "market-listings",
data: {
asset: assetId,
seller: seller.id,
quantity: 3,
price: 100,
desiredPrice: 100,
minPrice: 60,
currency: currencyId,
status: "active",
listedAt: new Date().toISOString(),
},
overrideAccess: true,
depth: 0,
})) as unknown as MarketListing;
const populated = (await payload.findByID({
collection: "market-listings",
id: listing.id,
depth: 1,
overrideAccess: true,
})) as unknown as MarketListing;
await expect(buyListing(payload, populated, buyer, { quantity: 0 })).rejects.toThrow(
/Quantity must be between/,
);
await expect(buyListing(payload, populated, buyer, { quantity: -1 })).rejects.toThrow(
/Quantity must be between/,
);
await expect(buyListing(payload, populated, buyer, { quantity: 4 })).rejects.toThrow(
/Quantity must be between/,
);
await expect(buyListing(payload, populated, buyer, { quantity: 1.5 })).rejects.toThrow(
/Quantity must be between/,
);
});
});