Advertisement

← octto

tests/session/sessions.test.ts

11.1 KB Download file
// tests/session/sessions.test.ts
import { afterEach, beforeEach, describe, expect, it } from "bun:test";

import { createSessionStore, type SessionStore } from "../../src/session/sessions";

describe("createSessionStore", () => {
  let sessions: SessionStore;

  beforeEach(() => {
    sessions = createSessionStore({ skipBrowser: true });
  });

  afterEach(async () => {
    await sessions.cleanup();
  });

  describe("startSession", () => {
    it("should create a session with unique ID", async () => {
      const result = await sessions.startSession({ title: "Test Session" });

      expect(result.session_id).toMatch(/^ses_[a-z0-9]{8}$/);
      expect(result.url).toMatch(/^http:\/\/localhost:\d+$/);
    });

    it("should create multiple sessions with different IDs", async () => {
      const result1 = await sessions.startSession({});
      const result2 = await sessions.startSession({});

      expect(result1.session_id).not.toBe(result2.session_id);
      expect(result1.url).not.toBe(result2.url);
    });

    it("should use configured port when provided", async () => {
      const fixedPortSessions = createSessionStore({ skipBrowser: true, port: 9876 });
      try {
        const result = await fixedPortSessions.startSession({ title: "Fixed Port Session" });
        expect(result.url).toBe("http://localhost:9876");
      } finally {
        await fixedPortSessions.cleanup();
      }
    });

    it("should use random port when port is 0", async () => {
      const randomPortSessions = createSessionStore({ skipBrowser: true, port: 0 });
      try {
        const result = await randomPortSessions.startSession({});
        expect(result.url).toMatch(/^http:\/\/localhost:\d+$/);
        // Port should be assigned (not 0)
        const port = parseInt(result.url.split(":")[2], 10);
        expect(port).toBeGreaterThan(0);
      } finally {
        await randomPortSessions.cleanup();
      }
    });
  });

  describe("endSession", () => {
    it("should end an existing session", async () => {
      const { session_id } = await sessions.startSession({});

      const result = await sessions.endSession(session_id);

      expect(result.ok).toBe(true);
    });

    it("should return ok=false for non-existent session", async () => {
      const result = await sessions.endSession("ses_nonexistent");

      expect(result.ok).toBe(false);
    });
  });

  describe("pushQuestion", () => {
    it("should push a question and return question ID", async () => {
      const { session_id } = await sessions.startSession({});

      const result = sessions.pushQuestion(session_id, "pick_one", {
        question: "Test question",
        options: [{ id: "a", label: "Option A" }],
      });

      expect(result.question_id).toMatch(/^q_[a-z0-9]{8}$/);
    });

    it("should throw for non-existent session", async () => {
      expect(() => {
        sessions.pushQuestion("ses_nonexistent", "pick_one", {
          question: "Test",
          options: [],
        });
      }).toThrow("Session not found");
    });
  });

  describe("getAnswer", () => {
    it("should return pending status for unanswered question", async () => {
      const { session_id } = await sessions.startSession({});
      const { question_id } = sessions.pushQuestion(session_id, "confirm", {
        question: "Test?",
      });

      const result = await sessions.getAnswer({ question_id, block: false });

      expect(result.completed).toBe(false);
      expect(result.status).toBe("pending");
    });

    it("should return cancelled status for non-existent question", async () => {
      const result = await sessions.getAnswer({ question_id: "q_nonexistent", block: false });

      expect(result.completed).toBe(false);
      expect(result.status).toBe("cancelled");
    });
  });

  describe("cancelQuestion", () => {
    it("should cancel a pending question", async () => {
      const { session_id } = await sessions.startSession({});
      const { question_id } = sessions.pushQuestion(session_id, "confirm", {
        question: "Test?",
      });

      const result = sessions.cancelQuestion(question_id);

      expect(result.ok).toBe(true);
    });

    it("should return ok=false for non-existent question", () => {
      const result = sessions.cancelQuestion("q_nonexistent");

      expect(result.ok).toBe(false);
    });
  });

  describe("listQuestions", () => {
    it("should list all questions across sessions", async () => {
      const { session_id } = await sessions.startSession({});
      sessions.pushQuestion(session_id, "confirm", { question: "Q1?" });
      sessions.pushQuestion(session_id, "pick_one", { question: "Q2?", options: [] });

      const result = sessions.listQuestions();

      expect(result.questions.length).toBe(2);
    });

    it("should filter by session ID", async () => {
      const { session_id: s1 } = await sessions.startSession({});
      const { session_id: s2 } = await sessions.startSession({});
      sessions.pushQuestion(s1, "confirm", { question: "Q1?" });
      sessions.pushQuestion(s2, "confirm", { question: "Q2?" });

      const result = sessions.listQuestions(s1);

      expect(result.questions.length).toBe(1);
    });
  });

  describe("getNextAnswer", () => {
    it("should timeout when blocking with no answers", async () => {
      const { session_id } = await sessions.startSession({});
      sessions.pushQuestion(session_id, "confirm", { question: "Test?" });

      const startTime = Date.now();
      const result = await sessions.getNextAnswer({ session_id, block: true, timeout: 100 });
      const elapsed = Date.now() - startTime;

      expect(result.completed).toBe(false);
      expect(result.status).toBe("timeout");
      expect(elapsed).toBeGreaterThanOrEqual(100);
    });
  });

  describe("WebSocket lifecycle", () => {
    const mockWs = { send: () => {} } as any;

    describe("handleWsConnect", () => {
      it("should mark session as connected", async () => {
        const { session_id } = await sessions.startSession({});

        // The session starts disconnected
        const sessionBefore = sessions.getSession(session_id);
        expect(sessionBefore?.wsConnected).toBe(false);

        // Simulate WebSocket connection
        sessions.handleWsConnect(session_id, mockWs);

        const sessionAfter = sessions.getSession(session_id);
        expect(sessionAfter?.wsConnected).toBe(true);
      });
    });

    describe("handleWsDisconnect", () => {
      it("should mark session as disconnected", async () => {
        const { session_id } = await sessions.startSession({});

        // Connect first
        sessions.handleWsConnect(session_id, mockWs);
        expect(sessions.getSession(session_id)?.wsConnected).toBe(true);

        // Then disconnect
        sessions.handleWsDisconnect(session_id);
        expect(sessions.getSession(session_id)?.wsConnected).toBe(false);
      });
    });

    describe("concurrent waiters", () => {
      it("should handle multiple waiters for same question", async () => {
        const { session_id } = await sessions.startSession({});
        const { question_id } = sessions.pushQuestion(session_id, "confirm", {
          question: "Test?",
        });

        // Start two concurrent waits
        const wait1 = sessions.getAnswer({
          question_id,
          block: true,
          timeout: 1000,
        });
        const wait2 = sessions.getAnswer({
          question_id,
          block: true,
          timeout: 1000,
        });

        // Simulate answer via WebSocket message
        sessions.handleWsMessage(session_id, {
          type: "response",
          id: question_id,
          answer: { choice: "yes" },
        });

        // Both should resolve
        const [result1, result2] = await Promise.all([wait1, wait2]);

        expect(result1.completed).toBe(true);
        expect(result2.completed).toBe(true);
        expect(result1.response).toEqual({ choice: "yes" });
        expect(result2.response).toEqual({ choice: "yes" });
      });

      it("should handle multiple session waiters correctly", async () => {
        const { session_id } = await sessions.startSession({});
        const { question_id: q1_id } = sessions.pushQuestion(session_id, "confirm", { question: "Q1?" });
        const { question_id: q2_id } = sessions.pushQuestion(session_id, "confirm", { question: "Q2?" });

        // Start two concurrent session-level waits
        const wait1 = sessions.getNextAnswer({
          session_id,
          block: true,
          timeout: 1000,
        });
        const wait2 = sessions.getNextAnswer({
          session_id,
          block: true,
          timeout: 1000,
        });

        // Submit first answer
        sessions.handleWsMessage(session_id, {
          type: "response",
          id: q1_id,
          answer: { choice: "yes" },
        });

        // First waiter should get first answer
        const result1 = await wait1;
        expect(result1.completed).toBe(true);
        expect(result1.question_id).toBe(q1_id);

        // Submit second answer
        sessions.handleWsMessage(session_id, {
          type: "response",
          id: q2_id,
          answer: { choice: "no" },
        });

        // Second waiter should get second answer
        const result2 = await wait2;
        expect(result2.completed).toBe(true);
        expect(result2.question_id).toBe(q2_id);
      });
    });

    describe("freetext answers", () => {
      it("should deliver the other freetext of a pick_one answer to a blocked caller", async () => {
        const { session_id } = await sessions.startSession({});
        const { question_id } = sessions.pushQuestion(session_id, "pick_one", {
          question: "Which datastore?",
          options: [{ id: "redis", label: "Redis" }],
          allowOther: true,
        });

        const wait = sessions.getAnswer({ question_id, block: true, timeout: 1000 });
        sessions.handleWsMessage(session_id, {
          type: "response",
          id: question_id,
          answer: { selected: "other", other: "SQLite with Litestream" },
        });

        const result = await wait;
        expect(result.completed).toBe(true);
        expect(result.response).toEqual({ selected: "other", other: "SQLite with Litestream" });
      });

      it("should deliver a pick_many answer mixing option ids with other freetext", async () => {
        const { session_id } = await sessions.startSession({});
        const { question_id } = sessions.pushQuestion(session_id, "pick_many", {
          question: "Which caches?",
          options: [{ id: "redis", label: "Redis" }],
          allowOther: true,
        });

        const wait = sessions.getAnswer({ question_id, block: true, timeout: 1000 });
        sessions.handleWsMessage(session_id, {
          type: "response",
          id: question_id,
          answer: { selected: ["redis", "other"], other: "Dragonfly" },
        });

        const result = await wait;
        expect(result.response).toEqual({ selected: ["redis", "other"], other: "Dragonfly" });
      });
    });
  });
});