Deploy to VPS / deploy (push) Failing after 1m43s
- Added new dependencies for Next.js and lucide-react in pnpm-workspace.yaml. - Refactored DashboardPage component to improve readability and error handling. - Enhanced Header component to display error status with an alert icon. - Updated MobileTabBar and Sidebar components to use a centralized tabs definition. - Improved ChannelsView in dashboard-panel to handle channel fetching more cleanly. - Fixed ActiveSpeaker type to use camelCase for userId. - Updated MessagesPanel to handle guildId checks more gracefully. - Adjusted API calls in dashboard and messages to align with backend expectations. - Refined type definitions across various interfaces for consistency and clarity.
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
// ─── Shared Error Classes ────────────────────────────────────────────────────
|
|
import {
|
|
AppError,
|
|
ConfigError,
|
|
DatabaseError,
|
|
NotFoundError,
|
|
UnauthorizedError,
|
|
ValidationError,
|
|
} from "@bete/shared/errors";
|
|
// ─── Shared utilities ─────────────────────────────────────────────────────────
|
|
import {
|
|
decodeCursor,
|
|
delay,
|
|
encodeCursor,
|
|
pageResult,
|
|
retryWithBackoff,
|
|
} from "@bete/shared/utils";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// ─── Backend middleware ──────────────────────────────────────────────────────
|
|
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// 1. AppError / Error Hierarchy Tests
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
describe("AppError subclasses", () => {
|
|
it("AppError stores message, code, statusCode, and details", () => {
|
|
const err = new AppError("custom", "CUSTOM", 418, { reason: "teapot" });
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect(err.message).toBe("custom");
|
|
expect(err.code).toBe("CUSTOM");
|
|
expect(err.statusCode).toBe(418);
|
|
expect(err.details).toEqual({ reason: "teapot" });
|
|
expect(err.name).toBe("AppError");
|
|
});
|
|
|
|
it("AppError defaults statusCode to 500", () => {
|
|
const err = new AppError("msg", "X");
|
|
expect(err.statusCode).toBe(500);
|
|
});
|
|
|
|
it("NotFoundError has 404 status and formatted message", () => {
|
|
const err = new NotFoundError("User");
|
|
expect(err).toBeInstanceOf(AppError);
|
|
expect(err.statusCode).toBe(404);
|
|
expect(err.code).toBe("NOT_FOUND");
|
|
expect(err.message).toBe("User not found");
|
|
expect(err.name).toBe("NotFoundError");
|
|
});
|
|
|
|
it("NotFoundError appends id when provided", () => {
|
|
const err = new NotFoundError("Message", "abc-123");
|
|
expect(err.message).toBe("Message not found: abc-123");
|
|
});
|
|
|
|
it("ValidationError has 400 status and forwards details", () => {
|
|
const details = { field: "email" };
|
|
const err = new ValidationError("Invalid input", details);
|
|
expect(err).toBeInstanceOf(AppError);
|
|
expect(err.statusCode).toBe(400);
|
|
expect(err.code).toBe("VALIDATION_ERROR");
|
|
expect(err.details).toBe(details);
|
|
expect(err.name).toBe("ValidationError");
|
|
});
|
|
|
|
it("UnauthorizedError has 401 status and default message", () => {
|
|
const err = new UnauthorizedError();
|
|
expect(err.statusCode).toBe(401);
|
|
expect(err.code).toBe("UNAUTHORIZED");
|
|
expect(err.message).toBe("Unauthorized");
|
|
});
|
|
|
|
it("UnauthorizedError accepts custom message", () => {
|
|
const err = new UnauthorizedError("Access denied");
|
|
expect(err.message).toBe("Access denied");
|
|
});
|
|
|
|
it("DatabaseError has 500 status and forwards details", () => {
|
|
const err = new DatabaseError("DB down", { cause: "timeout" });
|
|
expect(err.statusCode).toBe(500);
|
|
expect(err.code).toBe("DATABASE_ERROR");
|
|
expect(err.details).toEqual({ cause: "timeout" });
|
|
});
|
|
|
|
it("ConfigError has 500 status", () => {
|
|
const err = new ConfigError("Bad config");
|
|
expect(err.statusCode).toBe(500);
|
|
expect(err.code).toBe("CONFIG_ERROR");
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// 2. Utility Function Tests
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
describe("delay", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("resolves after the given time", async () => {
|
|
vi.useFakeTimers();
|
|
const promise = delay(500);
|
|
vi.advanceTimersByTime(500);
|
|
await expect(promise).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("rejects are not triggered on non-matching timer", async () => {
|
|
vi.useFakeTimers();
|
|
const promise = delay(1000);
|
|
// Advance only part way — the timer should NOT fire yet
|
|
vi.advanceTimersByTime(500);
|
|
// The timer is still pending; the promise has not resolved yet
|
|
// We advance the rest
|
|
vi.advanceTimersByTime(500);
|
|
await expect(promise).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("retryWithBackoff", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("returns the result on first success without retrying", async () => {
|
|
const fn = vi.fn().mockResolvedValue("ok");
|
|
await expect(retryWithBackoff(fn)).resolves.toBe("ok");
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("re-throws after exhausting all retries", async () => {
|
|
const fn = vi.fn().mockRejectedValue(new Error("persistent"));
|
|
await expect(
|
|
retryWithBackoff(fn, { retries: 1, minTimeout: 1, maxTimeout: 5 }),
|
|
).rejects.toThrow("persistent");
|
|
// initial call + 1 retry
|
|
expect(fn.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it("throws AbortError immediately when signal is already aborted", async () => {
|
|
const ac = new AbortController();
|
|
ac.abort();
|
|
const fn = vi.fn().mockResolvedValue("ok");
|
|
await expect(
|
|
retryWithBackoff(fn, { retries: 3, signal: ac.signal }),
|
|
).rejects.toThrow("Aborted");
|
|
expect(fn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("respects abort signal during retry", async () => {
|
|
vi.useFakeTimers();
|
|
const ac = new AbortController();
|
|
const fn = vi.fn().mockRejectedValue(new Error("fail"));
|
|
|
|
const promise = retryWithBackoff(fn, {
|
|
retries: 5,
|
|
minTimeout: 100,
|
|
signal: ac.signal,
|
|
});
|
|
|
|
// Schedule abort after first failure + backoff starts
|
|
setTimeout(() => ac.abort(), 150);
|
|
vi.advanceTimersByTime(200);
|
|
await vi.waitFor(async () => {
|
|
await expect(promise).rejects.toThrow("Aborted");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("pagination utilities", () => {
|
|
it("encodeCursor produces a base64 string", () => {
|
|
const result = encodeCursor({ created_at: 1000, id: "msg-1" });
|
|
expect(typeof result).toBe("string");
|
|
expect(result.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("encodeCursor round-trips through decodeCursor", () => {
|
|
const data = { created_at: 1234567890, id: "abc-def" };
|
|
const cursor = encodeCursor(data);
|
|
expect(decodeCursor(cursor)).toEqual(data);
|
|
});
|
|
|
|
it("decodeCursor returns null for undefined / empty", () => {
|
|
expect(decodeCursor()).toBeNull();
|
|
expect(decodeCursor("")).toBeNull();
|
|
});
|
|
|
|
it("decodeCursor returns null for malformed input", () => {
|
|
// Completely invalid base64
|
|
expect(decodeCursor("!!!not-valid!!!")).toBeNull();
|
|
// Valid base64 but not JSON
|
|
const notJson = Buffer.from("not-json").toString("base64");
|
|
expect(decodeCursor(notJson)).toBeNull();
|
|
// Valid JSON but wrong shape (missing created_at / id)
|
|
const wrongShape = Buffer.from(JSON.stringify({ foo: "bar" })).toString(
|
|
"base64",
|
|
);
|
|
expect(decodeCursor(wrongShape)).toBeNull();
|
|
});
|
|
|
|
it("pageResult truncates and sets nextCursor when rows exceed limit", () => {
|
|
const rows = [
|
|
{ id: "a", created_at: 100 },
|
|
{ id: "b", created_at: 200 },
|
|
{ id: "c", created_at: 300 },
|
|
];
|
|
const { data, nextCursor } = pageResult(rows, 2);
|
|
expect(data).toHaveLength(2);
|
|
expect(data[0].id).toBe("a");
|
|
expect(nextCursor).toBeTruthy();
|
|
});
|
|
|
|
it("pageResult returns null nextCursor when fewer rows than limit", () => {
|
|
const rows = [{ id: "a", created_at: 100 }];
|
|
const { data, nextCursor } = pageResult(rows, 2);
|
|
expect(data).toHaveLength(1);
|
|
expect(nextCursor).toBeNull();
|
|
});
|
|
|
|
it("pageResult returns empty data for empty input", () => {
|
|
const { data, nextCursor } = pageResult([], 10);
|
|
expect(data).toEqual([]);
|
|
expect(nextCursor).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// 3. Middleware Tests
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
describe("asyncHandler", () => {
|
|
it("passes thrown errors to next()", async () => {
|
|
const error = new Error("handler-error");
|
|
const wrapped = asyncHandler(async () => {
|
|
throw error;
|
|
});
|
|
const next = vi.fn();
|
|
|
|
wrapped({} as any, {} as any, next);
|
|
|
|
// .catch(next) is a microtask — flush the queue
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it("does not call next when handler resolves successfully", async () => {
|
|
const wrapped = asyncHandler(async (_req: any, _res: any, _next: any) => {
|
|
// no-op
|
|
});
|
|
const next = vi.fn();
|
|
|
|
wrapped({} as any, {} as any, next);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("requireParam", () => {
|
|
it("returns the value for a non-empty string", () => {
|
|
expect(requireParam("hello", "param", "name")).toBe("hello");
|
|
});
|
|
|
|
it("throws ValidationError for undefined", () => {
|
|
expect(() => requireParam(undefined, "query", "q")).toThrow(
|
|
ValidationError,
|
|
);
|
|
});
|
|
|
|
it("throws ValidationError for empty string", () => {
|
|
expect(() => requireParam("", "param", "id")).toThrow(ValidationError);
|
|
});
|
|
|
|
it("throws with a descriptive message", () => {
|
|
expect(() => requireParam(null, "header", "X-Token")).toThrow(
|
|
"Missing header: X-Token",
|
|
);
|
|
});
|
|
});
|