Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions api/tests/test_rag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
from __future__ import annotations

import sys
from pathlib import Path

import pytest

API_ROOT = Path(__file__).resolve().parents[1]
sys.path.append(str(API_ROOT))

from src.services.rag import chunk_text, _local_embed, similarity, build_prompt, _normalize_vector
from src.config import load_settings
import numpy as np


class TestChunkText:
def test_empty_string(self):
assert chunk_text("") == []

def test_short_text_single_chunk(self):
result = chunk_text("hello world")
assert len(result) == 1
assert result[0] == "hello world"

def test_respects_max_tokens(self):
text = " ".join(f"word{i}" for i in range(500))
chunks = chunk_text(text, max_tokens=100, overlap=20)
for chunk in chunks:
assert len(chunk.split()) <= 100

def test_overlap_produces_more_chunks(self):
text = " ".join(f"w{i}" for i in range(200))
no_overlap = chunk_text(text, max_tokens=100, overlap=0)
with_overlap = chunk_text(text, max_tokens=100, overlap=40)
assert len(with_overlap) > len(no_overlap)

def test_invalid_max_tokens(self):
with pytest.raises(ValueError):
chunk_text("hello", max_tokens=0)

def test_covers_all_tokens(self):
words = [f"w{i}" for i in range(50)]
text = " ".join(words)
chunks = chunk_text(text, max_tokens=20, overlap=5)
reconstructed = set()
for chunk in chunks:
reconstructed.update(chunk.split())
assert reconstructed == set(words)


class TestLocalEmbed:
def test_returns_correct_dimension(self):
vec = _local_embed("hello world")
assert len(vec) == 256

def test_empty_input(self):
vec = _local_embed("")
assert len(vec) == 256
assert all(v == 0.0 for v in vec)

def test_normalized(self):
vec = _local_embed("test embedding normalization")
norm = sum(v ** 2 for v in vec) ** 0.5
assert abs(norm - 1.0) < 1e-5

def test_deterministic(self):
v1 = _local_embed("same text")
v2 = _local_embed("same text")
assert v1 == v2

def test_different_texts_different_vectors(self):
v1 = _local_embed("hello")
v2 = _local_embed("completely different text")
assert v1 != v2


class TestSimilarity:
def test_identical_vectors(self):
vec = _local_embed("test")
score = similarity(vec, vec)
assert abs(score - 1.0) < 1e-5

def test_empty_vectors(self):
assert similarity([], []) == 0.0

def test_zero_vector(self):
zero = [0.0] * 256
vec = _local_embed("test")
assert similarity(zero, vec) == 0.0

def test_range(self):
v1 = _local_embed("hello world")
v2 = _local_embed("goodbye moon")
score = similarity(v1, v2)
assert -1.0 <= score <= 1.0


class TestNormalizeVector:
def test_unit_vector(self):
vec = np.array([3.0, 4.0])
result = _normalize_vector(vec)
assert abs(np.linalg.norm(result) - 1.0) < 1e-6

def test_zero_vector(self):
vec = np.array([0.0, 0.0])
result = _normalize_vector(vec)
assert np.allclose(result, vec)


class TestBuildPrompt:
def test_includes_question(self):
prompt = build_prompt("test question", [])
assert "test question" in prompt

def test_includes_context(self):
chunks = [{"text": "chunk one"}, {"text": "chunk two"}]
prompt = build_prompt("q", chunks)
assert "chunk one" in prompt
assert "chunk two" in prompt
assert "[1]" in prompt
assert "[2]" in prompt

def test_korean_instructions(self):
prompt = build_prompt("q", [])
assert "한국어" in prompt


class TestLoadSettings:
def test_defaults(self, monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.delenv("GEMINI_CHAT_MODEL", raising=False)
settings = load_settings()
assert settings.gemini_api_key is None
assert settings.chat_model == "gemini-2.5-flash"
assert settings.embedding_model == "text-embedding-004"
assert settings.api_timeout == 30

def test_custom_values(self, monkeypatch):
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
monkeypatch.setenv("GEMINI_CHAT_MODEL", "custom-model")
monkeypatch.setenv("GEMINI_REQUEST_TIMEOUT", "60")
settings = load_settings()
assert settings.gemini_api_key == "test-key"
assert settings.chat_model == "custom-model"
assert settings.api_timeout == 60
58 changes: 58 additions & 0 deletions app/app/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fetchApi, ApiError, ENDPOINT } from "../lib";

describe("ENDPOINT", () => {
it("has all required endpoints", () => {
expect(ENDPOINT.health).toContain("/api/v1/health");
expect(ENDPOINT.documents).toContain("/api/v1/documents");
expect(ENDPOINT.queries).toContain("/api/v1/queries");
expect(ENDPOINT.metrics).toContain("/api/v1/metrics");
expect(ENDPOINT.actions).toContain("/api/v1/agent/actions");
});
});

describe("ApiError", () => {
it("has name, message, and status", () => {
const err = new ApiError("Not Found", 404);
expect(err.name).toBe("ApiError");
expect(err.message).toBe("Not Found");
expect(err.status).toBe(404);
expect(err).toBeInstanceOf(Error);
});
});

describe("fetchApi", () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it("returns parsed JSON on success", async () => {
const data = { status: "ok" };
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(data),
} as Response);

const result = await fetchApi<{ status: string }>("http://localhost/test");
expect(result).toEqual(data);
});

it("throws ApiError on non-ok response", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
} as Response);

await expect(fetchApi("http://localhost/test")).rejects.toThrow(ApiError);
await expect(fetchApi("http://localhost/test")).rejects.toMatchObject({
status: 500,
});
});

it("propagates network errors", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));

await expect(fetchApi("http://localhost/test")).rejects.toThrow(TypeError);
});
});
185 changes: 185 additions & 0 deletions app/app/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import HomePage from "../page";

function mockFetch(overrides: Record<string, unknown> = {}) {
const defaults: Record<string, unknown> = {
"/api/v1/health": { status: "ok" },
"/api/v1/documents": [],
"/api/v1/metrics": {
documents: 0,
chunks: 0,
queries: 0,
avg_query_latency_ms: 0,
feedback_count: 0,
avg_feedback_rating: null,
},
...overrides,
};

return vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
const url = typeof input === "string" ? input : (input as Request).url;
for (const [key, value] of Object.entries(defaults)) {
if (url.includes(key)) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(value),
} as Response);
}
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
} as Response);
});
}

describe("HomePage", () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it("renders headings", async () => {
mockFetch();
render(<HomePage />);
expect(screen.getByText("Knowledge Copilot")).toBeInTheDocument();
expect(screen.getByText("1) 문서 업로드")).toBeInTheDocument();
expect(screen.getByText("2) 질의")).toBeInTheDocument();
expect(screen.getByText("3) 액션")).toBeInTheDocument();
expect(screen.getByText("4) 문서/메트릭")).toBeInTheDocument();
});

it("shows empty state when no documents", async () => {
mockFetch();
render(<HomePage />);
await waitFor(() => {
expect(screen.getByText("업로드된 문서가 없습니다.")).toBeInTheDocument();
});
});

it("shows offline banner when health check fails", async () => {
vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
const url = typeof input === "string" ? input : (input as Request).url;
if (url.includes("/health")) {
return Promise.reject(new Error("offline"));
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(url.includes("/documents") ? [] : {}),
} as Response);
});

render(<HomePage />);
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent("API 서버에 연결할 수 없습니다");
});
});

it("validates empty upload text", async () => {
mockFetch();
const user = userEvent.setup();
render(<HomePage />);

const uploadBtn = screen.getByText("문서 업로드");
await user.click(uploadBtn);

await waitFor(() => {
expect(screen.getByText("텍스트를 입력하세요")).toBeInTheDocument();
});
});

it("validates empty question", async () => {
mockFetch();
const user = userEvent.setup();
render(<HomePage />);

const askBtn = screen.getByText("질의 보내기");
await user.click(askBtn);

await waitFor(() => {
expect(screen.getByText("질문을 입력하세요")).toBeInTheDocument();
});
});

it("shows loading state during upload", async () => {
let resolveUpload: (v: Response) => void;
const uploadPromise = new Promise<Response>((r) => { resolveUpload = r; });

vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
const url = typeof input === "string" ? input : (input as Request).url;
if (url.includes("/documents") && !url.includes("?")) {
return uploadPromise;
}
if (url.includes("/health")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) } as Response);
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(url.includes("/documents") ? [] : {}),
} as Response);
});

const user = userEvent.setup();
render(<HomePage />);

const textarea = screen.getByPlaceholderText("질문 답변의 근거가 될 텍스트를 넣어주세요");
await user.type(textarea, "test text");

const uploadBtn = screen.getByText("문서 업로드");
await user.click(uploadBtn);

await waitFor(() => {
expect(screen.getByText("업로드 중...")).toBeInTheDocument();
});

resolveUpload!({ ok: true, json: () => Promise.resolve({ id: "1", status: "ready", chunk_count: 1 }) } as Response);
});

it("displays metrics when loaded", async () => {
mockFetch({
"/api/v1/metrics": {
documents: 3,
chunks: 15,
queries: 7,
avg_query_latency_ms: 250,
feedback_count: 2,
avg_feedback_rating: 4.5,
},
});

render(<HomePage />);

await waitFor(() => {
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("15")).toBeInTheDocument();
expect(screen.getByText("250ms")).toBeInTheDocument();
});
});

it("has proper ARIA attributes", () => {
mockFetch();
render(<HomePage />);

const forms = document.querySelectorAll("form");
forms.forEach((form) => {
expect(form).toHaveAttribute("aria-busy");
});

const sections = document.querySelectorAll("section[aria-labelledby]");
expect(sections.length).toBeGreaterThanOrEqual(3);
});

it("has htmlFor/id connections on form fields", () => {
mockFetch();
render(<HomePage />);

const projectLabel = screen.getByText("project_id");
expect(projectLabel).toHaveAttribute("for", "projectId");
expect(document.getElementById("projectId")).toBeInTheDocument();

const questionLabel = screen.getByText("질문");
expect(questionLabel).toHaveAttribute("for", "question");
expect(document.getElementById("question")).toBeInTheDocument();
});
});
7 changes: 7 additions & 0 deletions app/app/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

afterEach(() => {
cleanup();
});
Loading