feat: add extensive test suite
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
midpointOrder,
|
||||
normalizedOrders,
|
||||
ORDER_STEP,
|
||||
reorderFlatItems,
|
||||
} from "./order";
|
||||
|
||||
const items = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }];
|
||||
|
||||
describe("reorderFlatItems", () => {
|
||||
test.each([
|
||||
["a", "d", ["b", "c", "d", "a"]],
|
||||
["d", "a", ["d", "a", "b", "c"]],
|
||||
["b", "c", ["a", "c", "b", "d"]],
|
||||
["c", "b", ["a", "c", "b", "d"]],
|
||||
])("moves %s over %s", (active, over, expected) => {
|
||||
const result = reorderFlatItems(items, active, over);
|
||||
expect(result.map(({ id }) => id)).toEqual(expected);
|
||||
expect(result).not.toBe(items);
|
||||
expect(items.map(({ id }) => id)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
test("returns the original array when IDs are equal", () => {
|
||||
expect(reorderFlatItems(items, "b", "b")).toBe(items);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["missing", "a"],
|
||||
["a", "missing"],
|
||||
["missing", "other"],
|
||||
])("returns the original array for unknown IDs", (active, over) => {
|
||||
expect(reorderFlatItems(items, active, over)).toBe(items);
|
||||
});
|
||||
|
||||
test("moves the first matching item when IDs are duplicated", () => {
|
||||
const duplicate = [
|
||||
{ id: "a", value: 1 },
|
||||
{ id: "a", value: 2 },
|
||||
{ id: "b", value: 3 },
|
||||
];
|
||||
expect(
|
||||
reorderFlatItems(duplicate, "a", "b").map(({ value }) => value),
|
||||
).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
test("handles an empty array", () => {
|
||||
const empty: { id: string }[] = [];
|
||||
expect(reorderFlatItems(empty, "a", "b")).toBe(empty);
|
||||
});
|
||||
});
|
||||
|
||||
describe("midpointOrder", () => {
|
||||
test.each([
|
||||
[undefined, undefined, ORDER_STEP],
|
||||
[undefined, 30, 20],
|
||||
[20, undefined, 30],
|
||||
[10, 30, 20],
|
||||
[10, 31, 20],
|
||||
[-20, -10, -15],
|
||||
[0, 1, null],
|
||||
[1, 2, null],
|
||||
[-2, -1, null],
|
||||
[10, 10, null],
|
||||
] as const)("returns %p between %p and %p", (prev, next, expected) => {
|
||||
expect(midpointOrder(prev, next)).toBe(expected);
|
||||
});
|
||||
|
||||
test("treats null like a missing boundary", () => {
|
||||
expect(midpointOrder(null as unknown as undefined, 20)).toBe(10);
|
||||
expect(midpointOrder(20, null as unknown as undefined)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizedOrders", () => {
|
||||
test("assigns stable ORDER_STEP increments", () => {
|
||||
expect(normalizedOrders(["c", "a", "b"])).toEqual([
|
||||
{ id: "c", order: 10 },
|
||||
{ id: "a", order: 20 },
|
||||
{ id: "b", order: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns an empty list for no IDs", () => {
|
||||
expect(normalizedOrders([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves duplicate and empty IDs", () => {
|
||||
expect(normalizedOrders(["", "x", "x"])).toEqual([
|
||||
{ id: "", order: 10 },
|
||||
{ id: "x", order: 20 },
|
||||
{ id: "x", order: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not mutate the input", () => {
|
||||
const ids = ["b", "a"];
|
||||
normalizedOrders(ids);
|
||||
expect(ids).toEqual(["b", "a"]);
|
||||
});
|
||||
});
|
||||
@@ -24,13 +24,45 @@ export function midpointOrder(prev?: number, next?: number) {
|
||||
return middle;
|
||||
}
|
||||
|
||||
export function normalizedOrders(ids: string[]) {
|
||||
export function normalizedOrders(ids: readonly string[]) {
|
||||
return ids.map((id, index) => ({
|
||||
id,
|
||||
order: (index + 1) * ORDER_STEP,
|
||||
}));
|
||||
}
|
||||
|
||||
export type OrderItem = { id: string; order: number };
|
||||
|
||||
export function plannedOrderUpdates(
|
||||
items: OrderItem[],
|
||||
ids: readonly string[],
|
||||
activeId: string,
|
||||
staleMessage: string,
|
||||
) {
|
||||
const currentIds = items.map(({ id }) => id);
|
||||
const uniqueIds = new Set(ids);
|
||||
if (
|
||||
ids.length === 0 ||
|
||||
uniqueIds.size !== ids.length ||
|
||||
currentIds.length !== ids.length ||
|
||||
currentIds.some((id) => !uniqueIds.has(id))
|
||||
) {
|
||||
throw new Error(staleMessage);
|
||||
}
|
||||
|
||||
const index = ids.indexOf(activeId);
|
||||
if (index === -1) throw new Error("Moved item is missing.");
|
||||
|
||||
const byId = new Map(items.map((item) => [item.id, item]));
|
||||
const previous = index > 0 ? byId.get(ids[index - 1]) : undefined;
|
||||
const next = index < ids.length - 1 ? byId.get(ids[index + 1]) : undefined;
|
||||
const nextOrder = midpointOrder(previous?.order, next?.order);
|
||||
|
||||
return nextOrder == null
|
||||
? normalizedOrders(ids)
|
||||
: [{ id: activeId, order: nextOrder }];
|
||||
}
|
||||
|
||||
function arrayMove<T>(arr: T[], oldIndex: number, newIndex: number) {
|
||||
return arr.toSpliced(oldIndex, 1).toSpliced(newIndex, 0, arr[oldIndex]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { plannedOrderUpdates } from "./order";
|
||||
import { reorderService } from "./reorder-service";
|
||||
|
||||
const items = [
|
||||
{ id: "a", order: 10 },
|
||||
{ id: "b", order: 20 },
|
||||
{ id: "c", order: 30 },
|
||||
];
|
||||
|
||||
describe("plannedOrderUpdates", () => {
|
||||
test("uses a midpoint when sufficient order space exists", () => {
|
||||
expect(plannedOrderUpdates(items, ["b", "a", "c"], "a", "stale")).toEqual([
|
||||
{ id: "a", order: 25 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizes every item when adjacent orders have no midpoint", () => {
|
||||
expect(
|
||||
plannedOrderUpdates(
|
||||
[
|
||||
{ id: "a", order: 1 },
|
||||
{ id: "b", order: 2 },
|
||||
{ id: "c", order: 3 },
|
||||
],
|
||||
["b", "a", "c"],
|
||||
"a",
|
||||
"stale",
|
||||
),
|
||||
).toEqual([
|
||||
{ id: "b", order: 10 },
|
||||
{ id: "a", order: 20 },
|
||||
{ id: "c", order: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ ids: [] as string[] },
|
||||
{ ids: ["a", "a", "c"] },
|
||||
{ ids: ["a", "b"] },
|
||||
{ ids: ["a", "b", "stale"] },
|
||||
{ ids: ["a", "b", "c", "extra"] },
|
||||
])("rejects malformed, duplicate, or stale IDs: %p", ({ ids }) => {
|
||||
expect(() => plannedOrderUpdates(items, ids, "a", "refresh")).toThrow(
|
||||
"refresh",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an active ID absent from an otherwise valid order", () => {
|
||||
expect(() =>
|
||||
plannedOrderUpdates(items, ["a", "b", "c"], "missing", "stale"),
|
||||
).toThrow("Moved item is missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderService", () => {
|
||||
test("requires admin before opening a transaction", async () => {
|
||||
let transactionCalled = false;
|
||||
expect(
|
||||
reorderService("a", ["a"], "stale", {
|
||||
adminCheck: async () => false,
|
||||
transaction: async (callback) => {
|
||||
transactionCalled = true;
|
||||
return callback({
|
||||
list: async () => [],
|
||||
update: async () => {},
|
||||
});
|
||||
},
|
||||
afterReorder: async () => {},
|
||||
}),
|
||||
).rejects.toBe("Unauthorized");
|
||||
expect(transactionCalled).toBe(false);
|
||||
});
|
||||
|
||||
test("applies planned updates atomically before side effects", async () => {
|
||||
const calls: string[] = [];
|
||||
await reorderService("a", ["b", "a", "c"], "stale", {
|
||||
adminCheck: async () => true,
|
||||
transaction: async (callback) => {
|
||||
await callback({
|
||||
list: async () => items,
|
||||
update: async (id, order) => {
|
||||
calls.push(`update:${id}:${order}`);
|
||||
},
|
||||
});
|
||||
},
|
||||
afterReorder: async () => {
|
||||
calls.push("after");
|
||||
},
|
||||
});
|
||||
expect(calls).toEqual(["update:a:25", "after"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type OrderItem, plannedOrderUpdates } from "./order";
|
||||
|
||||
export interface ReorderDependencies {
|
||||
adminCheck: () => Promise<unknown>;
|
||||
transaction: (
|
||||
callback: (repository: {
|
||||
list: () => Promise<OrderItem[]>;
|
||||
update: (id: string, order: number) => Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
afterReorder: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export async function reorderService(
|
||||
activeId: string,
|
||||
ids: readonly string[],
|
||||
staleMessage: string,
|
||||
dependencies: ReorderDependencies,
|
||||
) {
|
||||
if (!(await dependencies.adminCheck())) throw "Unauthorized";
|
||||
|
||||
await dependencies.transaction(async (repository) => {
|
||||
const updates = plannedOrderUpdates(
|
||||
await repository.list(),
|
||||
ids,
|
||||
activeId,
|
||||
staleMessage,
|
||||
);
|
||||
for (const update of updates) {
|
||||
await repository.update(update.id, update.order);
|
||||
}
|
||||
});
|
||||
|
||||
await dependencies.afterReorder();
|
||||
}
|
||||
Reference in New Issue
Block a user