Files
puaros/packages/ipuaro/tests/unit/infrastructure/storage/RedisSessionStorage.test.ts
imfozilbek 0f2ed5b301 feat(ipuaro): add session management (v0.10.0)
- Add ISessionStorage interface and RedisSessionStorage implementation
- Add ContextManager for token budget and compression
- Add StartSession, HandleMessage, UndoChange use cases
- Update CHANGELOG and TODO documentation
- 88 new tests (1174 total), 97.73% coverage
2025-12-01 12:27:22 +05:00

391 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest"
import { RedisSessionStorage } from "../../../../src/infrastructure/storage/RedisSessionStorage.js"
import { RedisClient } from "../../../../src/infrastructure/storage/RedisClient.js"
import { Session } from "../../../../src/domain/entities/Session.js"
import type { UndoEntry } from "../../../../src/domain/value-objects/UndoEntry.js"
import { SessionKeys, SessionFields } from "../../../../src/infrastructure/storage/schema.js"
describe("RedisSessionStorage", () => {
let storage: RedisSessionStorage
let mockRedis: {
hset: ReturnType<typeof vi.fn>
hget: ReturnType<typeof vi.fn>
hgetall: ReturnType<typeof vi.fn>
del: ReturnType<typeof vi.fn>
lrange: ReturnType<typeof vi.fn>
lpush: ReturnType<typeof vi.fn>
lpos: ReturnType<typeof vi.fn>
lrem: ReturnType<typeof vi.fn>
rpush: ReturnType<typeof vi.fn>
rpop: ReturnType<typeof vi.fn>
llen: ReturnType<typeof vi.fn>
lpop: ReturnType<typeof vi.fn>
exists: ReturnType<typeof vi.fn>
pipeline: ReturnType<typeof vi.fn>
}
let mockClient: RedisClient
beforeEach(() => {
mockRedis = {
hset: vi.fn().mockResolvedValue(1),
hget: vi.fn().mockResolvedValue(null),
hgetall: vi.fn().mockResolvedValue({}),
del: vi.fn().mockResolvedValue(1),
lrange: vi.fn().mockResolvedValue([]),
lpush: vi.fn().mockResolvedValue(1),
lpos: vi.fn().mockResolvedValue(null),
lrem: vi.fn().mockResolvedValue(1),
rpush: vi.fn().mockResolvedValue(1),
rpop: vi.fn().mockResolvedValue(null),
llen: vi.fn().mockResolvedValue(0),
lpop: vi.fn().mockResolvedValue(null),
exists: vi.fn().mockResolvedValue(0),
pipeline: vi.fn().mockReturnValue({
hset: vi.fn().mockReturnThis(),
del: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue([]),
}),
}
mockClient = {
getClient: () => mockRedis,
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
} as unknown as RedisClient
storage = new RedisSessionStorage(mockClient)
})
describe("saveSession", () => {
it("should save session data to Redis", async () => {
const session = new Session("test-session-1", "test-project")
session.history = [{ role: "user", content: "Hello", timestamp: Date.now() }]
await storage.saveSession(session)
const pipeline = mockRedis.pipeline()
expect(pipeline.hset).toHaveBeenCalled()
expect(pipeline.exec).toHaveBeenCalled()
})
it("should add session to list if not exists", async () => {
const session = new Session("test-session-2", "test-project")
await storage.saveSession(session)
expect(mockRedis.lpos).toHaveBeenCalledWith(SessionKeys.list, "test-session-2")
expect(mockRedis.lpush).toHaveBeenCalledWith(SessionKeys.list, "test-session-2")
})
it("should not add session to list if already exists", async () => {
const session = new Session("existing-session", "test-project")
mockRedis.lpos.mockResolvedValue(0)
await storage.saveSession(session)
expect(mockRedis.lpush).not.toHaveBeenCalled()
})
})
describe("loadSession", () => {
it("should return null for non-existent session", async () => {
mockRedis.hgetall.mockResolvedValue({})
const result = await storage.loadSession("non-existent")
expect(result).toBeNull()
})
it("should load session from Redis", async () => {
const sessionData = {
[SessionFields.projectName]: "test-project",
[SessionFields.createdAt]: "1700000000000",
[SessionFields.lastActivityAt]: "1700001000000",
[SessionFields.history]: "[]",
[SessionFields.context]: JSON.stringify({
filesInContext: [],
tokenUsage: 0,
needsCompression: false,
}),
[SessionFields.stats]: JSON.stringify({
totalTokens: 0,
totalTimeMs: 0,
toolCalls: 0,
editsApplied: 0,
editsRejected: 0,
}),
[SessionFields.inputHistory]: "[]",
}
mockRedis.hgetall.mockResolvedValue(sessionData)
mockRedis.lrange.mockResolvedValue([])
const result = await storage.loadSession("test-session")
expect(result).not.toBeNull()
expect(result?.id).toBe("test-session")
expect(result?.projectName).toBe("test-project")
expect(result?.createdAt).toBe(1700000000000)
})
it("should load undo stack with session", async () => {
const sessionData = {
[SessionFields.projectName]: "test-project",
[SessionFields.createdAt]: "1700000000000",
[SessionFields.lastActivityAt]: "1700001000000",
[SessionFields.history]: "[]",
[SessionFields.context]: "{}",
[SessionFields.stats]: "{}",
[SessionFields.inputHistory]: "[]",
}
const undoEntry: UndoEntry = {
id: "undo-1",
timestamp: Date.now(),
filePath: "test.ts",
previousContent: ["old"],
newContent: ["new"],
description: "Edit",
}
mockRedis.hgetall.mockResolvedValue(sessionData)
mockRedis.lrange.mockResolvedValue([JSON.stringify(undoEntry)])
const result = await storage.loadSession("test-session")
expect(result?.undoStack).toHaveLength(1)
expect(result?.undoStack[0].id).toBe("undo-1")
})
})
describe("deleteSession", () => {
it("should delete session data and undo stack", async () => {
await storage.deleteSession("test-session")
expect(mockRedis.del).toHaveBeenCalledWith(SessionKeys.data("test-session"))
expect(mockRedis.del).toHaveBeenCalledWith(SessionKeys.undo("test-session"))
expect(mockRedis.lrem).toHaveBeenCalledWith(SessionKeys.list, 0, "test-session")
})
})
describe("listSessions", () => {
it("should return empty array when no sessions", async () => {
mockRedis.lrange.mockResolvedValue([])
const result = await storage.listSessions()
expect(result).toEqual([])
})
it("should list all sessions", async () => {
mockRedis.lrange.mockResolvedValue(["session-1", "session-2"])
mockRedis.hgetall.mockImplementation((key: string) => {
if (key.includes("session-1")) {
return Promise.resolve({
[SessionFields.projectName]: "project-1",
[SessionFields.createdAt]: "1700000000000",
[SessionFields.lastActivityAt]: "1700001000000",
[SessionFields.history]: "[]",
})
}
if (key.includes("session-2")) {
return Promise.resolve({
[SessionFields.projectName]: "project-2",
[SessionFields.createdAt]: "1700002000000",
[SessionFields.lastActivityAt]: "1700003000000",
[SessionFields.history]: '[{"role":"user","content":"Hi"}]',
})
}
return Promise.resolve({})
})
const result = await storage.listSessions()
expect(result).toHaveLength(2)
expect(result[0].id).toBe("session-2")
expect(result[1].id).toBe("session-1")
})
it("should filter by project name", async () => {
mockRedis.lrange.mockResolvedValue(["session-1", "session-2"])
mockRedis.hgetall.mockImplementation((key: string) => {
if (key.includes("session-1")) {
return Promise.resolve({
[SessionFields.projectName]: "project-1",
[SessionFields.createdAt]: "1700000000000",
[SessionFields.lastActivityAt]: "1700001000000",
[SessionFields.history]: "[]",
})
}
if (key.includes("session-2")) {
return Promise.resolve({
[SessionFields.projectName]: "project-2",
[SessionFields.createdAt]: "1700002000000",
[SessionFields.lastActivityAt]: "1700003000000",
[SessionFields.history]: "[]",
})
}
return Promise.resolve({})
})
const result = await storage.listSessions("project-1")
expect(result).toHaveLength(1)
expect(result[0].projectName).toBe("project-1")
})
})
describe("getLatestSession", () => {
it("should return null when no sessions", async () => {
mockRedis.lrange.mockResolvedValue([])
const result = await storage.getLatestSession("test-project")
expect(result).toBeNull()
})
it("should return the most recent session", async () => {
mockRedis.lrange.mockImplementation((key: string) => {
if (key === SessionKeys.list) {
return Promise.resolve(["session-1"])
}
return Promise.resolve([])
})
mockRedis.hgetall.mockResolvedValue({
[SessionFields.projectName]: "test-project",
[SessionFields.createdAt]: "1700000000000",
[SessionFields.lastActivityAt]: "1700001000000",
[SessionFields.history]: "[]",
[SessionFields.context]: "{}",
[SessionFields.stats]: "{}",
[SessionFields.inputHistory]: "[]",
})
const result = await storage.getLatestSession("test-project")
expect(result).not.toBeNull()
expect(result?.id).toBe("session-1")
})
})
describe("sessionExists", () => {
it("should return false for non-existent session", async () => {
mockRedis.exists.mockResolvedValue(0)
const result = await storage.sessionExists("non-existent")
expect(result).toBe(false)
})
it("should return true for existing session", async () => {
mockRedis.exists.mockResolvedValue(1)
const result = await storage.sessionExists("existing")
expect(result).toBe(true)
})
})
describe("undo stack operations", () => {
const undoEntry: UndoEntry = {
id: "undo-1",
timestamp: Date.now(),
filePath: "test.ts",
previousContent: ["old"],
newContent: ["new"],
description: "Edit",
}
describe("pushUndoEntry", () => {
it("should push undo entry to stack", async () => {
mockRedis.llen.mockResolvedValue(1)
await storage.pushUndoEntry("session-1", undoEntry)
expect(mockRedis.rpush).toHaveBeenCalledWith(
SessionKeys.undo("session-1"),
JSON.stringify(undoEntry),
)
})
it("should remove oldest entry when stack exceeds limit", async () => {
mockRedis.llen.mockResolvedValue(11)
await storage.pushUndoEntry("session-1", undoEntry)
expect(mockRedis.lpop).toHaveBeenCalledWith(SessionKeys.undo("session-1"))
})
})
describe("popUndoEntry", () => {
it("should return null for empty stack", async () => {
mockRedis.rpop.mockResolvedValue(null)
const result = await storage.popUndoEntry("session-1")
expect(result).toBeNull()
})
it("should pop and return undo entry", async () => {
mockRedis.rpop.mockResolvedValue(JSON.stringify(undoEntry))
const result = await storage.popUndoEntry("session-1")
expect(result).not.toBeNull()
expect(result?.id).toBe("undo-1")
})
})
describe("getUndoStack", () => {
it("should return empty array for empty stack", async () => {
mockRedis.lrange.mockResolvedValue([])
const result = await storage.getUndoStack("session-1")
expect(result).toEqual([])
})
it("should return all undo entries", async () => {
mockRedis.lrange.mockResolvedValue([
JSON.stringify({ ...undoEntry, id: "undo-1" }),
JSON.stringify({ ...undoEntry, id: "undo-2" }),
])
const result = await storage.getUndoStack("session-1")
expect(result).toHaveLength(2)
expect(result[0].id).toBe("undo-1")
expect(result[1].id).toBe("undo-2")
})
})
})
describe("touchSession", () => {
it("should update last activity timestamp", async () => {
const beforeTouch = Date.now()
await storage.touchSession("session-1")
expect(mockRedis.hset).toHaveBeenCalledWith(
SessionKeys.data("session-1"),
SessionFields.lastActivityAt,
expect.any(String),
)
const callArgs = mockRedis.hset.mock.calls[0]
const timestamp = Number(callArgs[2])
expect(timestamp).toBeGreaterThanOrEqual(beforeTouch)
})
})
describe("clearAllSessions", () => {
it("should clear all session data", async () => {
mockRedis.lrange.mockResolvedValue(["session-1", "session-2"])
await storage.clearAllSessions()
const pipeline = mockRedis.pipeline()
expect(pipeline.del).toHaveBeenCalled()
expect(pipeline.exec).toHaveBeenCalled()
})
})
})