mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat(ipuaro): implement Redis storage module (v0.2.0)
- Add RedisClient with connection management and AOF config - Add RedisStorage implementing full IStorage interface - Add Redis key schema for project and session data - Add generateProjectName() utility - Add 68 unit tests for Redis module (159 total) - Update ESLint: no-unnecessary-type-parameters as warn
This commit is contained in:
@@ -36,9 +36,7 @@ describe("ChatMessage", () => {
|
||||
})
|
||||
|
||||
it("should create assistant message with tool calls", () => {
|
||||
const toolCalls = [
|
||||
{ id: "1", name: "get_lines", params: {}, timestamp: Date.now() },
|
||||
]
|
||||
const toolCalls = [{ id: "1", name: "get_lines", params: {}, timestamp: Date.now() }]
|
||||
const stats = { tokens: 100, timeMs: 500, toolCalls: 1 }
|
||||
const msg = createAssistantMessage("Response", toolCalls, stats)
|
||||
|
||||
@@ -49,9 +47,7 @@ describe("ChatMessage", () => {
|
||||
|
||||
describe("createToolMessage", () => {
|
||||
it("should create tool message with results", () => {
|
||||
const results = [
|
||||
{ callId: "1", success: true, data: "data", executionTimeMs: 10 },
|
||||
]
|
||||
const results = [{ callId: "1", success: true, data: "data", executionTimeMs: 10 }]
|
||||
const msg = createToolMessage(results)
|
||||
|
||||
expect(msg.role).toBe("tool")
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
createFileData,
|
||||
isFileDataEqual,
|
||||
} from "../../../../src/domain/value-objects/FileData.js"
|
||||
import { createFileData, isFileDataEqual } from "../../../../src/domain/value-objects/FileData.js"
|
||||
|
||||
describe("FileData", () => {
|
||||
describe("createFileData", () => {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
createFileMeta,
|
||||
isHubFile,
|
||||
} from "../../../../src/domain/value-objects/FileMeta.js"
|
||||
import { createFileMeta, isHubFile } from "../../../../src/domain/value-objects/FileMeta.js"
|
||||
|
||||
describe("FileMeta", () => {
|
||||
describe("createFileMeta", () => {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import {
|
||||
createUndoEntry,
|
||||
canUndo,
|
||||
} from "../../../../src/domain/value-objects/UndoEntry.js"
|
||||
import { createUndoEntry, canUndo } from "../../../../src/domain/value-objects/UndoEntry.js"
|
||||
|
||||
describe("UndoEntry", () => {
|
||||
beforeEach(() => {
|
||||
@@ -21,7 +18,7 @@ describe("UndoEntry", () => {
|
||||
"test.ts",
|
||||
["old line"],
|
||||
["new line"],
|
||||
"Edit line 1"
|
||||
"Edit line 1",
|
||||
)
|
||||
|
||||
expect(entry.id).toBe("undo-1")
|
||||
@@ -34,14 +31,7 @@ describe("UndoEntry", () => {
|
||||
})
|
||||
|
||||
it("should create undo entry with toolCallId", () => {
|
||||
const entry = createUndoEntry(
|
||||
"undo-2",
|
||||
"test.ts",
|
||||
[],
|
||||
[],
|
||||
"Create file",
|
||||
"tool-123"
|
||||
)
|
||||
const entry = createUndoEntry("undo-2", "test.ts", [], [], "Create file", "tool-123")
|
||||
|
||||
expect(entry.toolCallId).toBe("tool-123")
|
||||
})
|
||||
@@ -49,37 +39,19 @@ describe("UndoEntry", () => {
|
||||
|
||||
describe("canUndo", () => {
|
||||
it("should return true when current content matches newContent", () => {
|
||||
const entry = createUndoEntry(
|
||||
"undo-1",
|
||||
"test.ts",
|
||||
["old"],
|
||||
["new"],
|
||||
"Edit"
|
||||
)
|
||||
const entry = createUndoEntry("undo-1", "test.ts", ["old"], ["new"], "Edit")
|
||||
|
||||
expect(canUndo(entry, ["new"])).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when content differs", () => {
|
||||
const entry = createUndoEntry(
|
||||
"undo-1",
|
||||
"test.ts",
|
||||
["old"],
|
||||
["new"],
|
||||
"Edit"
|
||||
)
|
||||
const entry = createUndoEntry("undo-1", "test.ts", ["old"], ["new"], "Edit")
|
||||
|
||||
expect(canUndo(entry, ["modified"])).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when length differs", () => {
|
||||
const entry = createUndoEntry(
|
||||
"undo-1",
|
||||
"test.ts",
|
||||
["old"],
|
||||
["new"],
|
||||
"Edit"
|
||||
)
|
||||
const entry = createUndoEntry("undo-1", "test.ts", ["old"], ["new"], "Edit")
|
||||
|
||||
expect(canUndo(entry, ["new", "extra"])).toBe(false)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import type { RedisConfig } from "../../../../src/shared/constants/config.js"
|
||||
import { IpuaroError } from "../../../../src/shared/errors/IpuaroError.js"
|
||||
|
||||
const mockRedisInstance = {
|
||||
connect: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
ping: vi.fn(),
|
||||
config: vi.fn(),
|
||||
status: "ready" as string,
|
||||
}
|
||||
|
||||
vi.mock("ioredis", () => {
|
||||
return {
|
||||
Redis: vi.fn(() => mockRedisInstance),
|
||||
}
|
||||
})
|
||||
|
||||
const { RedisClient } = await import("../../../../src/infrastructure/storage/RedisClient.js")
|
||||
|
||||
describe("RedisClient", () => {
|
||||
const defaultConfig: RedisConfig = {
|
||||
host: "localhost",
|
||||
port: 6379,
|
||||
db: 0,
|
||||
keyPrefix: "ipuaro:",
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRedisInstance.status = "ready"
|
||||
mockRedisInstance.connect.mockResolvedValue(undefined)
|
||||
mockRedisInstance.quit.mockResolvedValue(undefined)
|
||||
mockRedisInstance.ping.mockResolvedValue("PONG")
|
||||
mockRedisInstance.config.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create instance with config", () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
expect(client).toBeDefined()
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("connect", () => {
|
||||
it("should connect to Redis", async () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
await client.connect()
|
||||
|
||||
expect(mockRedisInstance.connect).toHaveBeenCalled()
|
||||
expect(client.isConnected()).toBe(true)
|
||||
})
|
||||
|
||||
it("should configure AOF on connect", async () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
await client.connect()
|
||||
|
||||
expect(mockRedisInstance.config).toHaveBeenCalledWith("SET", "appendonly", "yes")
|
||||
expect(mockRedisInstance.config).toHaveBeenCalledWith("SET", "appendfsync", "everysec")
|
||||
})
|
||||
|
||||
it("should not reconnect if already connected", async () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
await client.connect()
|
||||
await client.connect()
|
||||
|
||||
expect(mockRedisInstance.connect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should throw IpuaroError on connection failure", async () => {
|
||||
mockRedisInstance.connect.mockRejectedValue(new Error("Connection refused"))
|
||||
|
||||
const client = new RedisClient(defaultConfig)
|
||||
|
||||
await expect(client.connect()).rejects.toThrow(IpuaroError)
|
||||
await expect(client.connect()).rejects.toMatchObject({
|
||||
type: "redis",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle AOF config failure gracefully", async () => {
|
||||
mockRedisInstance.config.mockRejectedValue(new Error("CONFIG disabled"))
|
||||
|
||||
const client = new RedisClient(defaultConfig)
|
||||
await client.connect()
|
||||
|
||||
expect(client.isConnected()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("disconnect", () => {
|
||||
it("should disconnect from Redis", async () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
await client.connect()
|
||||
await client.disconnect()
|
||||
|
||||
expect(mockRedisInstance.quit).toHaveBeenCalled()
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle disconnect when not connected", async () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
await client.disconnect()
|
||||
|
||||
expect(mockRedisInstance.quit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isConnected", () => {
|
||||
it("should return false when not connected", () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when connected and ready", async () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
await client.connect()
|
||||
expect(client.isConnected()).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when client status is not ready", async () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
await client.connect()
|
||||
mockRedisInstance.status = "connecting"
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getClient", () => {
|
||||
it("should return Redis client when connected", async () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
await client.connect()
|
||||
|
||||
const redis = client.getClient()
|
||||
expect(redis).toBe(mockRedisInstance)
|
||||
})
|
||||
|
||||
it("should throw when not connected", () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
|
||||
expect(() => client.getClient()).toThrow(IpuaroError)
|
||||
expect(() => client.getClient()).toThrow("not connected")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ping", () => {
|
||||
it("should return true on successful ping", async () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
await client.connect()
|
||||
|
||||
const result = await client.ping()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when not connected", async () => {
|
||||
const client = new RedisClient(defaultConfig)
|
||||
|
||||
const result = await client.ping()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false on ping failure", async () => {
|
||||
mockRedisInstance.ping.mockRejectedValue(new Error("Timeout"))
|
||||
|
||||
const client = new RedisClient(defaultConfig)
|
||||
await client.connect()
|
||||
|
||||
const result = await client.ping()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,425 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { RedisStorage } from "../../../../src/infrastructure/storage/RedisStorage.js"
|
||||
import { RedisClient } from "../../../../src/infrastructure/storage/RedisClient.js"
|
||||
import type { FileData } from "../../../../src/domain/value-objects/FileData.js"
|
||||
import type { FileAST } from "../../../../src/domain/value-objects/FileAST.js"
|
||||
import type { FileMeta } from "../../../../src/domain/value-objects/FileMeta.js"
|
||||
import type { SymbolIndex, DepsGraph } from "../../../../src/domain/services/IStorage.js"
|
||||
import { IpuaroError } from "../../../../src/shared/errors/IpuaroError.js"
|
||||
|
||||
describe("RedisStorage", () => {
|
||||
const projectName = "test-project"
|
||||
let mockRedis: {
|
||||
hget: ReturnType<typeof vi.fn>
|
||||
hset: ReturnType<typeof vi.fn>
|
||||
hdel: ReturnType<typeof vi.fn>
|
||||
hgetall: ReturnType<typeof vi.fn>
|
||||
hlen: ReturnType<typeof vi.fn>
|
||||
del: ReturnType<typeof vi.fn>
|
||||
}
|
||||
let mockClient: {
|
||||
connect: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
isConnected: ReturnType<typeof vi.fn>
|
||||
getClient: ReturnType<typeof vi.fn>
|
||||
}
|
||||
let storage: RedisStorage
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = {
|
||||
hget: vi.fn(),
|
||||
hset: vi.fn(),
|
||||
hdel: vi.fn(),
|
||||
hgetall: vi.fn(),
|
||||
hlen: vi.fn(),
|
||||
del: vi.fn(),
|
||||
}
|
||||
|
||||
mockClient = {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
getClient: vi.fn().mockReturnValue(mockRedis),
|
||||
}
|
||||
|
||||
storage = new RedisStorage(mockClient as unknown as RedisClient, projectName)
|
||||
})
|
||||
|
||||
describe("File operations", () => {
|
||||
const testFile: FileData = {
|
||||
lines: ["line1", "line2"],
|
||||
hash: "abc123",
|
||||
size: 100,
|
||||
lastModified: Date.now(),
|
||||
}
|
||||
|
||||
describe("getFile", () => {
|
||||
it("should return file data when exists", async () => {
|
||||
mockRedis.hget.mockResolvedValue(JSON.stringify(testFile))
|
||||
|
||||
const result = await storage.getFile("src/index.ts")
|
||||
|
||||
expect(result).toEqual(testFile)
|
||||
expect(mockRedis.hget).toHaveBeenCalledWith(
|
||||
`project:${projectName}:files`,
|
||||
"src/index.ts",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return null when file not found", async () => {
|
||||
mockRedis.hget.mockResolvedValue(null)
|
||||
|
||||
const result = await storage.getFile("nonexistent.ts")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should throw on invalid JSON", async () => {
|
||||
mockRedis.hget.mockResolvedValue("invalid json")
|
||||
|
||||
await expect(storage.getFile("test.ts")).rejects.toThrow(IpuaroError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("setFile", () => {
|
||||
it("should store file data", async () => {
|
||||
await storage.setFile("src/index.ts", testFile)
|
||||
|
||||
expect(mockRedis.hset).toHaveBeenCalledWith(
|
||||
`project:${projectName}:files`,
|
||||
"src/index.ts",
|
||||
JSON.stringify(testFile),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteFile", () => {
|
||||
it("should delete file data", async () => {
|
||||
await storage.deleteFile("src/index.ts")
|
||||
|
||||
expect(mockRedis.hdel).toHaveBeenCalledWith(
|
||||
`project:${projectName}:files`,
|
||||
"src/index.ts",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAllFiles", () => {
|
||||
it("should return all files as Map", async () => {
|
||||
mockRedis.hgetall.mockResolvedValue({
|
||||
"src/a.ts": JSON.stringify(testFile),
|
||||
"src/b.ts": JSON.stringify({ ...testFile, hash: "def456" }),
|
||||
})
|
||||
|
||||
const result = await storage.getAllFiles()
|
||||
|
||||
expect(result).toBeInstanceOf(Map)
|
||||
expect(result.size).toBe(2)
|
||||
expect(result.get("src/a.ts")).toEqual(testFile)
|
||||
})
|
||||
|
||||
it("should return empty Map when no files", async () => {
|
||||
mockRedis.hgetall.mockResolvedValue({})
|
||||
|
||||
const result = await storage.getAllFiles()
|
||||
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getFileCount", () => {
|
||||
it("should return file count", async () => {
|
||||
mockRedis.hlen.mockResolvedValue(42)
|
||||
|
||||
const result = await storage.getFileCount()
|
||||
|
||||
expect(result).toBe(42)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("AST operations", () => {
|
||||
const testAST: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
describe("getAST", () => {
|
||||
it("should return AST when exists", async () => {
|
||||
mockRedis.hget.mockResolvedValue(JSON.stringify(testAST))
|
||||
|
||||
const result = await storage.getAST("src/index.ts")
|
||||
|
||||
expect(result).toEqual(testAST)
|
||||
})
|
||||
|
||||
it("should return null when not found", async () => {
|
||||
mockRedis.hget.mockResolvedValue(null)
|
||||
|
||||
const result = await storage.getAST("nonexistent.ts")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("setAST", () => {
|
||||
it("should store AST", async () => {
|
||||
await storage.setAST("src/index.ts", testAST)
|
||||
|
||||
expect(mockRedis.hset).toHaveBeenCalledWith(
|
||||
`project:${projectName}:ast`,
|
||||
"src/index.ts",
|
||||
JSON.stringify(testAST),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteAST", () => {
|
||||
it("should delete AST", async () => {
|
||||
await storage.deleteAST("src/index.ts")
|
||||
|
||||
expect(mockRedis.hdel).toHaveBeenCalledWith(
|
||||
`project:${projectName}:ast`,
|
||||
"src/index.ts",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAllASTs", () => {
|
||||
it("should return all ASTs as Map", async () => {
|
||||
mockRedis.hgetall.mockResolvedValue({
|
||||
"src/a.ts": JSON.stringify(testAST),
|
||||
})
|
||||
|
||||
const result = await storage.getAllASTs()
|
||||
|
||||
expect(result).toBeInstanceOf(Map)
|
||||
expect(result.size).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Meta operations", () => {
|
||||
const testMeta: FileMeta = {
|
||||
complexity: { loc: 10, nesting: 2, cyclomaticComplexity: 5, score: 20 },
|
||||
dependencies: ["./other.ts"],
|
||||
dependents: [],
|
||||
isHub: false,
|
||||
isEntryPoint: false,
|
||||
fileType: "source",
|
||||
}
|
||||
|
||||
describe("getMeta", () => {
|
||||
it("should return meta when exists", async () => {
|
||||
mockRedis.hget.mockResolvedValue(JSON.stringify(testMeta))
|
||||
|
||||
const result = await storage.getMeta("src/index.ts")
|
||||
|
||||
expect(result).toEqual(testMeta)
|
||||
})
|
||||
|
||||
it("should return null when not found", async () => {
|
||||
mockRedis.hget.mockResolvedValue(null)
|
||||
|
||||
const result = await storage.getMeta("nonexistent.ts")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("setMeta", () => {
|
||||
it("should store meta", async () => {
|
||||
await storage.setMeta("src/index.ts", testMeta)
|
||||
|
||||
expect(mockRedis.hset).toHaveBeenCalledWith(
|
||||
`project:${projectName}:meta`,
|
||||
"src/index.ts",
|
||||
JSON.stringify(testMeta),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteMeta", () => {
|
||||
it("should delete meta", async () => {
|
||||
await storage.deleteMeta("src/index.ts")
|
||||
|
||||
expect(mockRedis.hdel).toHaveBeenCalledWith(
|
||||
`project:${projectName}:meta`,
|
||||
"src/index.ts",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAllMetas", () => {
|
||||
it("should return all metas as Map", async () => {
|
||||
mockRedis.hgetall.mockResolvedValue({
|
||||
"src/a.ts": JSON.stringify(testMeta),
|
||||
})
|
||||
|
||||
const result = await storage.getAllMetas()
|
||||
|
||||
expect(result).toBeInstanceOf(Map)
|
||||
expect(result.size).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Index operations", () => {
|
||||
describe("getSymbolIndex", () => {
|
||||
it("should return symbol index", async () => {
|
||||
const index: [string, { path: string; line: number; type: string }[]][] = [
|
||||
["MyClass", [{ path: "src/index.ts", line: 10, type: "class" }]],
|
||||
]
|
||||
mockRedis.hget.mockResolvedValue(JSON.stringify(index))
|
||||
|
||||
const result = await storage.getSymbolIndex()
|
||||
|
||||
expect(result).toBeInstanceOf(Map)
|
||||
expect(result.get("MyClass")).toBeDefined()
|
||||
})
|
||||
|
||||
it("should return empty Map when not found", async () => {
|
||||
mockRedis.hget.mockResolvedValue(null)
|
||||
|
||||
const result = await storage.getSymbolIndex()
|
||||
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("setSymbolIndex", () => {
|
||||
it("should store symbol index", async () => {
|
||||
const index: SymbolIndex = new Map([
|
||||
["MyClass", [{ path: "src/index.ts", line: 10, type: "class" }]],
|
||||
])
|
||||
|
||||
await storage.setSymbolIndex(index)
|
||||
|
||||
expect(mockRedis.hset).toHaveBeenCalledWith(
|
||||
`project:${projectName}:indexes`,
|
||||
"symbols",
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getDepsGraph", () => {
|
||||
it("should return deps graph", async () => {
|
||||
const graph = {
|
||||
imports: [["a.ts", ["b.ts"]]],
|
||||
importedBy: [["b.ts", ["a.ts"]]],
|
||||
}
|
||||
mockRedis.hget.mockResolvedValue(JSON.stringify(graph))
|
||||
|
||||
const result = await storage.getDepsGraph()
|
||||
|
||||
expect(result.imports).toBeInstanceOf(Map)
|
||||
expect(result.importedBy).toBeInstanceOf(Map)
|
||||
})
|
||||
|
||||
it("should return empty graph when not found", async () => {
|
||||
mockRedis.hget.mockResolvedValue(null)
|
||||
|
||||
const result = await storage.getDepsGraph()
|
||||
|
||||
expect(result.imports.size).toBe(0)
|
||||
expect(result.importedBy.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("setDepsGraph", () => {
|
||||
it("should store deps graph", async () => {
|
||||
const graph: DepsGraph = {
|
||||
imports: new Map([["a.ts", ["b.ts"]]]),
|
||||
importedBy: new Map([["b.ts", ["a.ts"]]]),
|
||||
}
|
||||
|
||||
await storage.setDepsGraph(graph)
|
||||
|
||||
expect(mockRedis.hset).toHaveBeenCalledWith(
|
||||
`project:${projectName}:indexes`,
|
||||
"deps_graph",
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Config operations", () => {
|
||||
describe("getProjectConfig", () => {
|
||||
it("should return config value", async () => {
|
||||
mockRedis.hget.mockResolvedValue(JSON.stringify({ key: "value" }))
|
||||
|
||||
const result = await storage.getProjectConfig("settings")
|
||||
|
||||
expect(result).toEqual({ key: "value" })
|
||||
})
|
||||
|
||||
it("should return null when not found", async () => {
|
||||
mockRedis.hget.mockResolvedValue(null)
|
||||
|
||||
const result = await storage.getProjectConfig("nonexistent")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("setProjectConfig", () => {
|
||||
it("should store config value", async () => {
|
||||
await storage.setProjectConfig("settings", { key: "value" })
|
||||
|
||||
expect(mockRedis.hset).toHaveBeenCalledWith(
|
||||
`project:${projectName}:config`,
|
||||
"settings",
|
||||
JSON.stringify({ key: "value" }),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Lifecycle operations", () => {
|
||||
describe("connect", () => {
|
||||
it("should delegate to client", async () => {
|
||||
await storage.connect()
|
||||
|
||||
expect(mockClient.connect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("disconnect", () => {
|
||||
it("should delegate to client", async () => {
|
||||
await storage.disconnect()
|
||||
|
||||
expect(mockClient.disconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isConnected", () => {
|
||||
it("should delegate to client", () => {
|
||||
mockClient.isConnected.mockReturnValue(true)
|
||||
|
||||
expect(storage.isConnected()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clear", () => {
|
||||
it("should delete all project keys", async () => {
|
||||
mockRedis.del.mockResolvedValue(1)
|
||||
|
||||
await storage.clear()
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledTimes(5)
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(`project:${projectName}:files`)
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(`project:${projectName}:ast`)
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(`project:${projectName}:meta`)
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(`project:${projectName}:indexes`)
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(`project:${projectName}:config`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
110
packages/ipuaro/tests/unit/infrastructure/storage/schema.test.ts
Normal file
110
packages/ipuaro/tests/unit/infrastructure/storage/schema.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
ProjectKeys,
|
||||
SessionKeys,
|
||||
IndexFields,
|
||||
SessionFields,
|
||||
generateProjectName,
|
||||
} from "../../../../src/infrastructure/storage/schema.js"
|
||||
|
||||
describe("schema", () => {
|
||||
describe("ProjectKeys", () => {
|
||||
it("should generate files key", () => {
|
||||
expect(ProjectKeys.files("myproject")).toBe("project:myproject:files")
|
||||
})
|
||||
|
||||
it("should generate ast key", () => {
|
||||
expect(ProjectKeys.ast("myproject")).toBe("project:myproject:ast")
|
||||
})
|
||||
|
||||
it("should generate meta key", () => {
|
||||
expect(ProjectKeys.meta("myproject")).toBe("project:myproject:meta")
|
||||
})
|
||||
|
||||
it("should generate indexes key", () => {
|
||||
expect(ProjectKeys.indexes("myproject")).toBe("project:myproject:indexes")
|
||||
})
|
||||
|
||||
it("should generate config key", () => {
|
||||
expect(ProjectKeys.config("myproject")).toBe("project:myproject:config")
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionKeys", () => {
|
||||
it("should generate data key", () => {
|
||||
expect(SessionKeys.data("session-123")).toBe("session:session-123:data")
|
||||
})
|
||||
|
||||
it("should generate undo key", () => {
|
||||
expect(SessionKeys.undo("session-123")).toBe("session:session-123:undo")
|
||||
})
|
||||
|
||||
it("should have list key", () => {
|
||||
expect(SessionKeys.list).toBe("sessions:list")
|
||||
})
|
||||
})
|
||||
|
||||
describe("IndexFields", () => {
|
||||
it("should have symbols field", () => {
|
||||
expect(IndexFields.symbols).toBe("symbols")
|
||||
})
|
||||
|
||||
it("should have depsGraph field", () => {
|
||||
expect(IndexFields.depsGraph).toBe("deps_graph")
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionFields", () => {
|
||||
it("should have all required fields", () => {
|
||||
expect(SessionFields.history).toBe("history")
|
||||
expect(SessionFields.context).toBe("context")
|
||||
expect(SessionFields.stats).toBe("stats")
|
||||
expect(SessionFields.inputHistory).toBe("input_history")
|
||||
expect(SessionFields.createdAt).toBe("created_at")
|
||||
expect(SessionFields.lastActivityAt).toBe("last_activity_at")
|
||||
expect(SessionFields.projectName).toBe("project_name")
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateProjectName", () => {
|
||||
it("should generate name from path with two parts", () => {
|
||||
expect(generateProjectName("/home/user/projects/myapp")).toBe("projects-myapp")
|
||||
})
|
||||
|
||||
it("should generate name from single directory", () => {
|
||||
expect(generateProjectName("/app")).toBe("app")
|
||||
})
|
||||
|
||||
it("should handle root path", () => {
|
||||
expect(generateProjectName("/")).toBe("root")
|
||||
})
|
||||
|
||||
it("should handle empty path", () => {
|
||||
expect(generateProjectName("")).toBe("root")
|
||||
})
|
||||
|
||||
it("should handle trailing slashes", () => {
|
||||
expect(generateProjectName("/home/user/projects/myapp/")).toBe("projects-myapp")
|
||||
})
|
||||
|
||||
it("should handle Windows paths", () => {
|
||||
expect(generateProjectName("C:\\Users\\projects\\myapp")).toBe("projects-myapp")
|
||||
})
|
||||
|
||||
it("should sanitize special characters", () => {
|
||||
expect(generateProjectName("/home/my project/my@app!")).toBe("my-project-my-app")
|
||||
})
|
||||
|
||||
it("should convert to lowercase", () => {
|
||||
expect(generateProjectName("/Home/User/MYAPP")).toBe("user-myapp")
|
||||
})
|
||||
|
||||
it("should handle multiple consecutive special chars", () => {
|
||||
expect(generateProjectName("/home/my___project")).toBe("home-my-project")
|
||||
})
|
||||
|
||||
it("should handle relative paths", () => {
|
||||
expect(generateProjectName("parent/child")).toBe("parent-child")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { loadConfig, validateConfig, getConfigErrors } from "../../../../src/shared/config/loader.js"
|
||||
import {
|
||||
loadConfig,
|
||||
validateConfig,
|
||||
getConfigErrors,
|
||||
} from "../../../../src/shared/config/loader.js"
|
||||
import { DEFAULT_CONFIG } from "../../../../src/shared/constants/config.js"
|
||||
import * as fs from "node:fs"
|
||||
|
||||
@@ -28,7 +32,7 @@ describe("config loader", () => {
|
||||
return path === "/project/.ipuaro.json"
|
||||
})
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify({ llm: { model: "custom-model" } })
|
||||
JSON.stringify({ llm: { model: "custom-model" } }),
|
||||
)
|
||||
|
||||
const config = loadConfig("/project")
|
||||
|
||||
Reference in New Issue
Block a user