Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions app/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion app/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "echo 'Skipping frontend checks in backend release pipeline' || exit 0"
"test": "echo 'Skipping frontend checks in backend release pipeline' || exit 0",
"test:unit": "vitest run",
"type-check": "tsc --noEmit -p tsconfig.test.json"
},
"dependencies": {
"i18next": "^26.0.1",
Expand Down
118 changes: 118 additions & 0 deletions app/frontend/src/__tests__/bidUpdates.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import { applyBidUpdate, applyLocalBid } from "@/lib/bidUpdates";
import type { MarketplaceListing } from "@/hooks/marketplaceApi";

function makeListing(
overrides: Partial<MarketplaceListing> = {},
): MarketplaceListing {
return {
id: "1",
username: "nova",
currentBid: 1000,
buyNowPrice: null,
ownerAddress: "GBXT...2R7K",
endsAt: new Date(Date.now() + 3600_000),
createdAt: new Date(Date.now() - 3600_000),
status: "auction",
category: "brand",
bidCount: 10,
watchers: 5,
verified: false,
...overrides,
};
}

describe("applyBidUpdate", () => {
it("applies an update with a higher bid and increments bidCount once", () => {
const listings = [makeListing(), makeListing({ id: "2", username: "lux" })];
const next = applyBidUpdate(listings, { listingId: "1", newBid: 1200 });

expect(next).not.toBe(listings);
expect(next[0].currentBid).toBe(1200);
expect(next[0].bidCount).toBe(11);
// Other listings untouched
expect(next[1]).toBe(listings[1]);
// Input not mutated
expect(listings[0].currentBid).toBe(1000);
});

it("discards a duplicate delivery (same bid) without touching bidCount", () => {
const listings = [makeListing()];
const next = applyBidUpdate(listings, { listingId: "1", newBid: 1000 });

expect(next).toBe(listings);
expect(next[0].bidCount).toBe(10);
});

it("discards a stale out-of-order delivery (lower bid)", () => {
const listings = [makeListing()];
const next = applyBidUpdate(listings, { listingId: "1", newBid: 700 });

expect(next).toBe(listings);
});

it("ignores updates for unknown listings", () => {
const listings = [makeListing()];
const next = applyBidUpdate(listings, { listingId: "999", newBid: 9999 });

expect(next).toBe(listings);
});

it("prefers the server's authoritative bidCount when provided", () => {
const listings = [makeListing()];
const next = applyBidUpdate(listings, {
listingId: "1",
newBid: 1500,
bidCount: 42,
});

expect(next[0].bidCount).toBe(42);
});

it("counts only genuinely new bids across an out-of-order burst", () => {
let listings = [makeListing()];
// Delivery order: new, duplicate, older, new
listings = applyBidUpdate(listings, { listingId: "1", newBid: 1200 });
listings = applyBidUpdate(listings, { listingId: "1", newBid: 1200 });
listings = applyBidUpdate(listings, { listingId: "1", newBid: 1100 });
listings = applyBidUpdate(listings, { listingId: "1", newBid: 1300 });

expect(listings[0].currentBid).toBe(1300);
expect(listings[0].bidCount).toBe(12); // 10 + the 2 real bids
});
});

describe("applyLocalBid", () => {
it("applies the user's own bid by username", () => {
const listings = [makeListing()];
const next = applyLocalBid(listings, "nova", 1250);

expect(next[0].currentBid).toBe(1250);
expect(next[0].bidCount).toBe(11);
});

it("does not double-count the websocket echo of the user's own bid", () => {
let listings = [makeListing()];
listings = applyLocalBid(listings, "nova", 1250);
// The feed echoes the same bid back later
listings = applyBidUpdate(listings, { listingId: "1", newBid: 1250 });

expect(listings[0].currentBid).toBe(1250);
expect(listings[0].bidCount).toBe(11);
});

it("cannot regress the price when the user's bid raced a higher update", () => {
let listings = [makeListing()];
// A realtime update lands first, then the user's slower bid succeeds
listings = applyBidUpdate(listings, { listingId: "1", newBid: 1400 });
listings = applyLocalBid(listings, "nova", 1250);

expect(listings[0].currentBid).toBe(1400);
expect(listings[0].bidCount).toBe(11);
});

it("ignores bids for unknown usernames", () => {
const listings = [makeListing()];
expect(applyLocalBid(listings, "ghost", 5000)).toBe(listings);
});
});
100 changes: 100 additions & 0 deletions app/frontend/src/__tests__/useRealtimeUpdates.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MockWebSocket, type BidUpdate } from "@/hooks/useRealtimeUpdates";
import { applyBidUpdate } from "@/lib/bidUpdates";
import type { MarketplaceListing } from "@/hooks/marketplaceApi";

const LISTING: MarketplaceListing = {
id: "a",
username: "nova",
currentBid: 1000,
buyNowPrice: null,
ownerAddress: "GBXT...2R7K",
endsAt: new Date(Date.now() + 3600_000),
createdAt: new Date(Date.now() - 3600_000),
status: "auction",
category: "brand",
bidCount: 10,
watchers: 5,
verified: false,
};

function collectUpdates(socket: MockWebSocket): BidUpdate[] {
const updates: BidUpdate[] = [];
socket.onBidUpdate((u) => updates.push(u));
return updates;
}

describe("MockWebSocket", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});

it("emits monotonically increasing bids seeded from the subscribed baseline", () => {
// Math.random() = 0.25: emit gate passes (< 0.3), stale-replay branch is
// skipped (>= 0.2), increment is a constant floor(0.25*100)+50 = 75.
vi.spyOn(Math, "random").mockReturnValue(0.25);

const socket = new MockWebSocket();
const updates = collectUpdates(socket);
socket.connect();
socket.subscribe("a", LISTING.currentBid);

vi.advanceTimersByTime(5 * 5000);
socket.disconnect();

expect(updates).toHaveLength(5);
expect(updates[0].newBid).toBeGreaterThan(LISTING.currentBid);
for (let i = 1; i < updates.length; i++) {
expect(updates[i].newBid).toBeGreaterThan(updates[i - 1].newBid);
}
});

it("replays stale bids, which the applyBidUpdate guard discards", () => {
// Math.random() = 0.1: emit gate passes (< 0.3) and every delivery takes
// the stale-replay branch (< 0.2), re-sending the baseline bid.
vi.spyOn(Math, "random").mockReturnValue(0.1);

const socket = new MockWebSocket();
const updates = collectUpdates(socket);
socket.connect();
socket.subscribe("a", LISTING.currentBid);

vi.advanceTimersByTime(3 * 5000);
socket.disconnect();

expect(updates).toHaveLength(3);
updates.forEach((u) => expect(u.newBid).toBe(LISTING.currentBid));

// Feeding the stale replays through the page's guard changes nothing:
// same array reference, bid count not inflated.
let listings = [LISTING];
for (const update of updates) {
const next = applyBidUpdate(listings, update);
expect(next).toBe(listings);
listings = next;
}
expect(listings[0].bidCount).toBe(10);
});

it("stops emitting after unsubscribe", () => {
vi.spyOn(Math, "random").mockReturnValue(0.25);

const socket = new MockWebSocket();
const updates = collectUpdates(socket);
socket.connect();
socket.subscribe("a", LISTING.currentBid);
vi.advanceTimersByTime(5000);
expect(updates).toHaveLength(1);

socket.unsubscribe("a");
vi.advanceTimersByTime(3 * 5000);
socket.disconnect();

expect(updates).toHaveLength(1);
});
});
2 changes: 1 addition & 1 deletion app/frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import "./globals.css";

const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
"https:// RustAcademy.to";
"https://RustAcademy.to";

export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
Expand Down
Loading
Loading