|
|
@@ -0,0 +1,90 @@
|
|
|
+/* @vitest-environment node */
|
|
|
+
|
|
|
+import { describe, it, expect } from "vitest";
|
|
|
+import { ApiError } from "@/lib/api/errors";
|
|
|
+import { encodeCursor, decodeCursor } from "./cursor.js";
|
|
|
+
|
|
|
+describe("lib/search/cursor", () => {
|
|
|
+ it("decodeCursor(null) returns the default cursor", () => {
|
|
|
+ expect(decodeCursor(null)).toEqual({
|
|
|
+ v: 1,
|
|
|
+ mode: "sync",
|
|
|
+ offset: 0,
|
|
|
+ contextId: null,
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('decodeCursor("") treats empty string like missing cursor (default)', () => {
|
|
|
+ expect(decodeCursor("")).toEqual({
|
|
|
+ v: 1,
|
|
|
+ mode: "sync",
|
|
|
+ offset: 0,
|
|
|
+ contextId: null,
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it("encodeCursor + decodeCursor roundtrip works", () => {
|
|
|
+ const encoded = encodeCursor({ v: 1, mode: "sync", offset: 10 });
|
|
|
+ expect(typeof encoded).toBe("string");
|
|
|
+ expect(encoded.length).toBeGreaterThan(5);
|
|
|
+
|
|
|
+ const decoded = decodeCursor(encoded);
|
|
|
+ expect(decoded).toEqual({
|
|
|
+ v: 1,
|
|
|
+ mode: "sync",
|
|
|
+ offset: 10,
|
|
|
+ contextId: null,
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it("keeps contextId when provided", () => {
|
|
|
+ const encoded = encodeCursor({
|
|
|
+ v: 1,
|
|
|
+ mode: "sync",
|
|
|
+ offset: 5,
|
|
|
+ contextId: "ctx-123",
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(decodeCursor(encoded)).toEqual({
|
|
|
+ v: 1,
|
|
|
+ mode: "sync",
|
|
|
+ offset: 5,
|
|
|
+ contextId: "ctx-123",
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it("encodeCursor rejects invalid payloads", () => {
|
|
|
+ expect(() => encodeCursor(null)).toThrow(ApiError);
|
|
|
+ expect(() => encodeCursor({})).toThrow(ApiError);
|
|
|
+ expect(() => encodeCursor({ offset: -1 })).toThrow(ApiError);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("decodeCursor rejects whitespace-only cursor", () => {
|
|
|
+ expect(() => decodeCursor(" ")).toThrow(ApiError);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("decodeCursor rejects non-JSON payloads (deterministic)", () => {
|
|
|
+ // Using a valid base64url string that decodes to non-JSON ensures deterministic failure:
|
|
|
+ // JSON.parse("not-json") must throw => decodeCursor must throw ApiError.
|
|
|
+ const bad = Buffer.from("not-json", "utf8").toString("base64url");
|
|
|
+ expect(() => decodeCursor(bad)).toThrow(ApiError);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("decodeCursor rejects wrong version", () => {
|
|
|
+ const bad = Buffer.from(
|
|
|
+ JSON.stringify({ v: 2, mode: "sync", offset: 0 }),
|
|
|
+ "utf8"
|
|
|
+ ).toString("base64url");
|
|
|
+
|
|
|
+ expect(() => decodeCursor(bad)).toThrow(ApiError);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("decodeCursor rejects negative offsets", () => {
|
|
|
+ const bad = Buffer.from(
|
|
|
+ JSON.stringify({ v: 1, mode: "sync", offset: -1 }),
|
|
|
+ "utf8"
|
|
|
+ ).toString("base64url");
|
|
|
+
|
|
|
+ expect(() => decodeCursor(bad)).toThrow(ApiError);
|
|
|
+ });
|
|
|
+});
|