From 1d7aebf37c3947e6a1bb2e5373460168adb185f7 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 25 Feb 2026 10:01:56 +0000 Subject: [PATCH] test: added more tests (#16) Co-authored-by: Sharang Parnerkar Reviewed-on: https://gitea.meghsakha.com/sharang/certifai/pulls/16 --- .gitea/workflows/ci.yml | 132 +++++++++++++- .gitignore | 5 + Cargo.lock | 65 +++++++ Cargo.toml | 4 + bun.lock | 9 + e2e/auth.setup.ts | 24 +++ e2e/auth.spec.ts | 72 ++++++++ e2e/dashboard.spec.ts | 75 ++++++++ e2e/developer.spec.ts | 33 ++++ e2e/navigation.spec.ts | 52 ++++++ e2e/organization.spec.ts | 41 +++++ e2e/providers.spec.ts | 55 ++++++ e2e/public.spec.ts | 60 +++++++ package.json | 1 + playwright.config.ts | 40 +++++ src/infrastructure/auth.rs | 131 +++++++++++++- src/infrastructure/chat.rs | 246 +++++++++++++++++++++++++- src/infrastructure/config.rs | 157 ++++++++++++++++ src/infrastructure/error.rs | 50 ++++++ src/infrastructure/llm.rs | 169 +++++++++++++++++- src/infrastructure/provider_client.rs | 27 +++ src/infrastructure/searxng.rs | 173 +++++++++++++++++- src/infrastructure/state.rs | 88 +++++++++ src/models/chat.rs | 160 +++++++++++++++++ src/models/developer.rs | 60 +++++++ src/models/news.rs | 58 ++++++ src/models/organization.rs | 119 +++++++++++++ src/models/provider.rs | 81 +++++++++ src/models/user.rs | 78 ++++++++ 29 files changed, 2243 insertions(+), 22 deletions(-) create mode 100644 e2e/auth.setup.ts create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/dashboard.spec.ts create mode 100644 e2e/developer.spec.ts create mode 100644 e2e/navigation.spec.ts create mode 100644 e2e/organization.spec.ts create mode 100644 e2e/providers.spec.ts create mode 100644 e2e/public.spec.ts create mode 100644 playwright.config.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6312edd..2f47959 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -120,13 +120,143 @@ jobs: run: sccache --show-stats if: always() + # --------------------------------------------------------------------------- + # Stage 2b: E2E tests (only on main / PRs to main, after quality checks) + # --------------------------------------------------------------------------- + e2e: + name: E2E Tests + runs-on: docker + needs: [fmt, clippy, audit] + if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request' + container: + image: rust:1.89-bookworm + # MongoDB and SearXNG can start immediately (no repo files needed). + # Keycloak requires realm-export.json from the repo, so it is started + # manually after checkout via docker CLI. + services: + mongo: + image: mongo:latest + env: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + ports: + - 27017:27017 + searxng: + image: searxng/searxng:latest + env: + SEARXNG_BASE_URL: http://localhost:8888 + ports: + - 8888:8080 + env: + KEYCLOAK_URL: http://localhost:8080 + KEYCLOAK_REALM: certifai + KEYCLOAK_CLIENT_ID: certifai-dashboard + MONGODB_URI: mongodb://root:example@mongo:27017 + MONGODB_DATABASE: certifai + SEARXNG_URL: http://searxng:8080 + steps: + - name: Checkout + run: | + git init + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --depth=1 origin "${GITHUB_SHA}" + git checkout FETCH_HEAD + - name: Install system dependencies + run: | + apt-get update -qq + apt-get install -y -qq --no-install-recommends \ + unzip curl docker.io \ + libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 \ + libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 \ + libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \ + libcairo2 libasound2 libatspi2.0-0 libxshmfence1 + - name: Start Keycloak + run: | + docker run -d --name ci-keycloak --network host \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ + -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + -e KC_DB=dev-mem \ + -e KC_HEALTH_ENABLED=true \ + -v "$PWD/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro" \ + -v "$PWD/keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro" \ + quay.io/keycloak/keycloak:26.0 start-dev --import-realm + + echo "Waiting for Keycloak..." + for i in $(seq 1 60); do + if curl -sf http://localhost:8080/realms/certifai > /dev/null 2>&1; then + echo "Keycloak is ready" + break + fi + if [ "$i" -eq 60 ]; then + echo "Keycloak failed to start within 60s" + docker logs ci-keycloak + exit 1 + fi + sleep 2 + done + - name: Install sccache + run: | + curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ + | tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + chmod +x /usr/local/bin/sccache + - name: Install dioxus-cli + run: cargo install dioxus-cli --locked + - name: Install bun + run: | + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" + - name: Install Playwright + run: | + export PATH="$HOME/.bun/bin:$PATH" + bun install + bunx playwright install chromium + - name: Build app + run: dx build --release + - name: Start app and run E2E tests + run: | + export PATH="$HOME/.bun/bin:$PATH" + # Start the app in the background + dx serve --release --port 8000 & + APP_PID=$! + + # Wait for the app to be ready + echo "Waiting for app to start..." + for i in $(seq 1 60); do + if curl -sf http://localhost:8000 > /dev/null 2>&1; then + echo "App is ready" + break + fi + if [ "$i" -eq 60 ]; then + echo "App failed to start within 60s" + exit 1 + fi + sleep 1 + done + + BASE_URL=http://localhost:8000 bunx playwright test --reporter=list + + kill "$APP_PID" 2>/dev/null || true + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + - name: Cleanup Keycloak + if: always() + run: docker rm -f ci-keycloak 2>/dev/null || true + - name: Show sccache stats + run: sccache --show-stats + if: always() + # --------------------------------------------------------------------------- # Stage 3: Deploy (only after tests pass, only on main) # --------------------------------------------------------------------------- deploy: name: Deploy runs-on: docker - needs: [test] + needs: [test, e2e] if: github.ref == 'refs/heads/main' container: image: alpine:latest diff --git a/.gitignore b/.gitignore index 75620fe..8e10faa 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ keycloak/* node_modules/ searxng/ + +# Playwright +e2e/.auth/ +playwright-report/ +test-results/ diff --git a/Cargo.lock b/Cargo.lock index 57bf95e..9e9ff42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,7 @@ dependencies = [ "maud", "mongodb", "petname", + "pretty_assertions", "pulldown-cmark", "rand 0.10.0", "reqwest 0.13.2", @@ -783,6 +784,7 @@ dependencies = [ "secrecy", "serde", "serde_json", + "serial_test", "sha2", "thiserror 2.0.18", "time", @@ -882,6 +884,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -3246,6 +3254,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -3823,6 +3841,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.28" @@ -3862,6 +3889,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "secrecy" version = "0.10.3" @@ -4082,6 +4115,32 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -5683,6 +5742,12 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yazi" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 8caa25c..9de0a77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,10 @@ server = [ "dep:bytes", ] +[dev-dependencies] +pretty_assertions = "1.4" +serial_test = "3.2" + [[bin]] name = "dashboard" path = "bin/main.rs" diff --git a/bun.lock b/bun.lock index 5d6c3e9..5849f07 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "tailwindcss": "^4.1.18", }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/bun": "latest", }, "peerDependencies": { @@ -16,6 +17,8 @@ }, }, "packages": { + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], @@ -24,6 +27,12 @@ "daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..e8b6cb1 --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,24 @@ +import { test as setup, expect } from "@playwright/test"; + +const AUTH_FILE = "e2e/.auth/user.json"; + +setup("authenticate via Keycloak", async ({ page }) => { + // Navigate to a protected route to trigger the auth redirect chain: + // /dashboard -> /auth (Axum) -> Keycloak login page + await page.goto("/dashboard"); + + // Wait for Keycloak login form to appear + await page.waitForSelector("#username", { timeout: 15_000 }); + + // Fill Keycloak credentials + await page.fill("#username", process.env.TEST_USER ?? "admin@certifai.local"); + await page.fill("#password", process.env.TEST_PASSWORD ?? "admin"); + await page.click("#kc-login"); + + // Wait for redirect back to the app dashboard + await page.waitForURL("**/dashboard", { timeout: 15_000 }); + await expect(page.locator(".sidebar")).toBeVisible(); + + // Persist authenticated state (cookies + localStorage) + await page.context().storageState({ path: AUTH_FILE }); +}); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..fe35ce6 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from "@playwright/test"; + +// These tests use a fresh browser context (no saved auth state) +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe("Authentication flow", () => { + test("unauthenticated visit to /dashboard redirects to Keycloak", async ({ + page, + }) => { + await page.goto("/dashboard"); + + // Should end up on Keycloak login page + await page.waitForSelector("#username", { timeout: 15_000 }); + await expect(page.locator("#kc-login")).toBeVisible(); + }); + + test("valid credentials log in and redirect to dashboard", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForSelector("#username", { timeout: 15_000 }); + + await page.fill( + "#username", + process.env.TEST_USER ?? "admin@certifai.local" + ); + await page.fill("#password", process.env.TEST_PASSWORD ?? "admin"); + await page.click("#kc-login"); + + await page.waitForURL("**/dashboard", { timeout: 15_000 }); + await expect(page.locator(".dashboard-page")).toBeVisible(); + }); + + test("dashboard shows sidebar with user info after login", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForSelector("#username", { timeout: 15_000 }); + + await page.fill( + "#username", + process.env.TEST_USER ?? "admin@certifai.local" + ); + await page.fill("#password", process.env.TEST_PASSWORD ?? "admin"); + await page.click("#kc-login"); + + await page.waitForURL("**/dashboard", { timeout: 15_000 }); + await expect(page.locator(".sidebar-name")).toBeVisible(); + await expect(page.locator(".sidebar-email")).toBeVisible(); + }); + + test("logout redirects away from dashboard", async ({ page }) => { + // First log in + await page.goto("/dashboard"); + await page.waitForSelector("#username", { timeout: 15_000 }); + + await page.fill( + "#username", + process.env.TEST_USER ?? "admin@certifai.local" + ); + await page.fill("#password", process.env.TEST_PASSWORD ?? "admin"); + await page.click("#kc-login"); + + await page.waitForURL("**/dashboard", { timeout: 15_000 }); + + // Click logout + await page.locator('a.logout-btn, a[href="/logout"]').click(); + + // Should no longer be on the dashboard + await expect(page).not.toHaveURL(/\/dashboard/); + }); +}); diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts new file mode 100644 index 0000000..89094e2 --- /dev/null +++ b/e2e/dashboard.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Dashboard", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/dashboard"); + // Wait for WASM hydration and auth check to complete + await page.waitForSelector(".dashboard-page", { timeout: 15_000 }); + }); + + test("dashboard page loads with page header", async ({ page }) => { + await expect(page.locator(".page-header")).toContainText("Dashboard"); + }); + + test("default topic chips are visible", async ({ page }) => { + const topics = ["AI", "Technology", "Science", "Finance", "Writing", "Research"]; + + for (const topic of topics) { + await expect( + page.locator(".filter-tab", { hasText: topic }) + ).toBeVisible(); + } + }); + + test("clicking a topic chip triggers search", async ({ page }) => { + const chip = page.locator(".filter-tab", { hasText: "AI" }); + await chip.click(); + + // Either a loading state or results should appear + const searchingOrResults = page + .locator(".dashboard-loading, .news-grid, .dashboard-empty"); + await expect(searchingOrResults.first()).toBeVisible({ timeout: 10_000 }); + }); + + test("news cards render after search completes", async ({ page }) => { + // Click a topic to trigger search + await page.locator(".filter-tab", { hasText: "Technology" }).click(); + + // Wait for loading to finish + await page.waitForSelector(".dashboard-loading", { + state: "hidden", + timeout: 15_000, + }).catch(() => { + // Loading may already be done + }); + + // Either news cards or an empty state message should be visible + const content = page.locator(".news-grid .news-card, .dashboard-empty"); + await expect(content.first()).toBeVisible({ timeout: 10_000 }); + }); + + test("clicking a news card opens article detail panel", async ({ page }) => { + // Trigger a search and wait for results + await page.locator(".filter-tab", { hasText: "AI" }).click(); + + await page.waitForSelector(".dashboard-loading", { + state: "hidden", + timeout: 15_000, + }).catch(() => {}); + + const firstCard = page.locator(".news-card").first(); + // Only test if cards are present (search results depend on live data) + if (await firstCard.isVisible().catch(() => false)) { + await firstCard.click(); + await expect(page.locator(".dashboard-right, .dashboard-split")).toBeVisible(); + } + }); + + test("settings toggle opens settings panel", async ({ page }) => { + const settingsBtn = page.locator(".settings-toggle"); + await settingsBtn.click(); + + await expect(page.locator(".settings-panel")).toBeVisible(); + await expect(page.locator(".settings-panel-title")).toBeVisible(); + }); +}); diff --git a/e2e/developer.spec.ts b/e2e/developer.spec.ts new file mode 100644 index 0000000..9d84e30 --- /dev/null +++ b/e2e/developer.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Developer section", () => { + test("agents page loads with sub-nav tabs", async ({ page }) => { + await page.goto("/developer/agents"); + await page.waitForSelector(".developer-shell", { timeout: 15_000 }); + + const nav = page.locator(".sub-nav"); + await expect(nav.locator("a", { hasText: "Agents" })).toBeVisible(); + await expect(nav.locator("a", { hasText: "Flow" })).toBeVisible(); + await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible(); + }); + + test("agents page shows Coming Soon badge", async ({ page }) => { + await page.goto("/developer/agents"); + await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); + + await expect(page.locator(".placeholder-badge")).toContainText( + "Coming Soon" + ); + await expect(page.locator("h2")).toContainText("Agent Builder"); + }); + + test("analytics page loads via sub-nav", async ({ page }) => { + await page.goto("/developer/analytics"); + await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); + + await expect(page.locator("h2")).toContainText("Analytics"); + await expect(page.locator(".placeholder-badge")).toContainText( + "Coming Soon" + ); + }); +}); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 0000000..cf3859e --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Sidebar navigation", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForSelector(".sidebar", { timeout: 15_000 }); + }); + + test("sidebar links route to correct pages", async ({ page }) => { + const navTests = [ + { label: "Providers", url: /\/providers/ }, + { label: "Developer", url: /\/developer\/agents/ }, + { label: "Organization", url: /\/organization\/pricing/ }, + { label: "Dashboard", url: /\/dashboard/ }, + ]; + + for (const { label, url } of navTests) { + await page.locator(".sidebar-link", { hasText: label }).click(); + await expect(page).toHaveURL(url, { timeout: 10_000 }); + } + }); + + test("browser back/forward navigation works", async ({ page }) => { + // Navigate to Providers + await page.locator(".sidebar-link", { hasText: "Providers" }).click(); + await expect(page).toHaveURL(/\/providers/); + + // Navigate to Developer + await page.locator(".sidebar-link", { hasText: "Developer" }).click(); + await expect(page).toHaveURL(/\/developer/); + + // Go back + await page.goBack(); + await expect(page).toHaveURL(/\/providers/); + + // Go forward + await page.goForward(); + await expect(page).toHaveURL(/\/developer/); + }); + + test("logo link navigates to dashboard", async ({ page }) => { + // Navigate away first + await page.locator(".sidebar-link", { hasText: "Providers" }).click(); + await expect(page).toHaveURL(/\/providers/); + + // Click the logo/brand in sidebar header + const logo = page.locator(".sidebar-brand, .sidebar-logo, .sidebar a").first(); + await logo.click(); + + await expect(page).toHaveURL(/\/dashboard/); + }); +}); diff --git a/e2e/organization.spec.ts b/e2e/organization.spec.ts new file mode 100644 index 0000000..c786254 --- /dev/null +++ b/e2e/organization.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Organization section", () => { + test("pricing page loads with three pricing cards", async ({ page }) => { + await page.goto("/organization/pricing"); + await page.waitForSelector(".org-shell", { timeout: 15_000 }); + + const cards = page.locator(".pricing-card"); + await expect(cards).toHaveCount(3); + }); + + test("pricing cards show Starter, Team, Enterprise tiers", async ({ + page, + }) => { + await page.goto("/organization/pricing"); + await page.waitForSelector(".org-shell", { timeout: 15_000 }); + + await expect(page.locator(".pricing-card", { hasText: "Starter" })).toBeVisible(); + await expect(page.locator(".pricing-card", { hasText: "Team" })).toBeVisible(); + await expect(page.locator(".pricing-card", { hasText: "Enterprise" })).toBeVisible(); + }); + + test("organization dashboard loads with billing stats", async ({ page }) => { + await page.goto("/organization/dashboard"); + await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 }); + + await expect(page.locator(".page-header")).toContainText("Organization"); + await expect(page.locator(".org-stats-bar")).toBeVisible(); + await expect(page.locator(".org-stat").first()).toBeVisible(); + }); + + test("member table is visible on org dashboard", async ({ page }) => { + await page.goto("/organization/dashboard"); + await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 }); + + await expect(page.locator(".org-table")).toBeVisible(); + await expect(page.locator(".org-table thead")).toContainText("Name"); + await expect(page.locator(".org-table thead")).toContainText("Email"); + await expect(page.locator(".org-table thead")).toContainText("Role"); + }); +}); diff --git a/e2e/providers.spec.ts b/e2e/providers.spec.ts new file mode 100644 index 0000000..63ea082 --- /dev/null +++ b/e2e/providers.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Providers page", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/providers"); + await page.waitForSelector(".providers-page", { timeout: 15_000 }); + }); + + test("providers page loads with header", async ({ page }) => { + await expect(page.locator(".page-header")).toContainText("Providers"); + }); + + test("provider dropdown has Ollama selected by default", async ({ + page, + }) => { + const providerSelect = page + .locator(".form-group") + .filter({ hasText: "Provider" }) + .locator("select"); + + await expect(providerSelect).toHaveValue(/ollama/i); + }); + + test("changing provider updates the model dropdown", async ({ page }) => { + const providerSelect = page + .locator(".form-group") + .filter({ hasText: "Provider" }) + .locator("select"); + + // Get current model options + const modelSelect = page + .locator(".form-group") + .filter({ hasText: /^Model/ }) + .locator("select"); + const initialOptions = await modelSelect.locator("option").allTextContents(); + + // Change to a different provider + await providerSelect.selectOption({ label: "OpenAI" }); + + // Wait for model list to update + await page.waitForTimeout(500); + const updatedOptions = await modelSelect.locator("option").allTextContents(); + + // Model options should differ between providers + expect(updatedOptions).not.toEqual(initialOptions); + }); + + test("save button shows confirmation feedback", async ({ page }) => { + const saveBtn = page.locator("button", { hasText: "Save Configuration" }); + await saveBtn.click(); + + await expect(page.locator(".form-success")).toBeVisible({ timeout: 5_000 }); + await expect(page.locator(".form-success")).toContainText("saved"); + }); +}); diff --git a/e2e/public.spec.ts b/e2e/public.spec.ts new file mode 100644 index 0000000..afd7f3b --- /dev/null +++ b/e2e/public.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Public pages", () => { + test("landing page loads with heading and nav links", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator(".landing-logo").first()).toHaveText("CERTifAI"); + await expect(page.locator(".landing-nav-links")).toBeVisible(); + await expect(page.locator('a[href="#features"]')).toBeVisible(); + await expect(page.locator('a[href="#how-it-works"]')).toBeVisible(); + await expect(page.locator('a[href="#pricing"]')).toBeVisible(); + }); + + test("landing page Log In link navigates to login route", async ({ + page, + }) => { + await page.goto("/"); + + const loginLink = page + .locator(".landing-nav-actions a, .landing-nav-actions Link") + .filter({ hasText: "Log In" }); + await loginLink.click(); + + await expect(page).toHaveURL(/\/login/); + }); + + test("impressum page loads with legal content", async ({ page }) => { + await page.goto("/impressum"); + + await expect(page.locator("h1")).toHaveText("Impressum"); + await expect( + page.locator("h2", { hasText: "Information according to" }) + ).toBeVisible(); + await expect(page.locator(".legal-content")).toContainText( + "CERTifAI GmbH" + ); + }); + + test("privacy page loads with privacy content", async ({ page }) => { + await page.goto("/privacy"); + + await expect(page.locator("h1")).toHaveText("Privacy Policy"); + await expect( + page.locator("h2", { hasText: "Introduction" }) + ).toBeVisible(); + await expect( + page.locator("h2", { hasText: "Your Rights" }) + ).toBeVisible(); + }); + + test("footer links are present on landing page", async ({ page }) => { + await page.goto("/"); + + const footer = page.locator(".landing-footer"); + await expect(footer.locator('a:has-text("Impressum")')).toBeVisible(); + await expect( + footer.locator('a:has-text("Privacy Policy")') + ).toBeVisible(); + }); +}); diff --git a/package.json b/package.json index 0072209..8ad4cbb 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "type": "module", "private": true, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/bun": "latest" }, "peerDependencies": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d58df60 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [["html"], ["list"]], + timeout: 30_000, + + use: { + baseURL: process.env.BASE_URL ?? "http://localhost:8000", + actionTimeout: 10_000, + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + + projects: [ + { + name: "setup", + testMatch: /auth\.setup\.ts/, + }, + { + name: "public", + testMatch: /public\.spec\.ts/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "authenticated", + testMatch: /\.spec\.ts$/, + testIgnore: /public\.spec\.ts$/, + dependencies: ["setup"], + use: { + ...devices["Desktop Chrome"], + storageState: "e2e/.auth/user.json", + }, + }, + ], +}); diff --git a/src/infrastructure/auth.rs b/src/infrastructure/auth.rs index 9894878..8c46836 100644 --- a/src/infrastructure/auth.rs +++ b/src/infrastructure/auth.rs @@ -24,9 +24,9 @@ pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user"; /// post-login redirect URL and the PKCE code verifier needed for the /// token exchange. #[derive(Debug, Clone)] -struct PendingOAuthEntry { - redirect_url: Option, - code_verifier: String, +pub(crate) struct PendingOAuthEntry { + pub(crate) redirect_url: Option, + pub(crate) code_verifier: String, } /// In-memory store for pending OAuth states. Keyed by the random state @@ -38,7 +38,7 @@ pub struct PendingOAuthStore(Arc>>); impl PendingOAuthStore { /// Insert a pending state with an optional redirect URL and PKCE verifier. - fn insert(&self, state: String, entry: PendingOAuthEntry) { + pub(crate) fn insert(&self, state: String, entry: PendingOAuthEntry) { // RwLock::write only panics if the lock is poisoned, which // indicates a prior panic -- propagating is acceptable here. #[allow(clippy::expect_used)] @@ -50,7 +50,7 @@ impl PendingOAuthStore { /// Remove and return the entry if the state was pending. /// Returns `None` if the state was never stored (CSRF failure). - fn take(&self, state: &str) -> Option { + pub(crate) fn take(&self, state: &str) -> Option { #[allow(clippy::expect_used)] self.0 .write() @@ -60,7 +60,8 @@ impl PendingOAuthStore { } /// Generate a cryptographically random state string for CSRF protection. -fn generate_state() -> String { +#[cfg_attr(test, allow(dead_code))] +pub(crate) fn generate_state() -> String { let bytes: [u8; 32] = rand::rng().random(); // Encode as hex to produce a URL-safe string without padding. bytes.iter().fold(String::with_capacity(64), |mut acc, b| { @@ -75,7 +76,7 @@ fn generate_state() -> String { /// /// Uses 32 random bytes encoded as base64url (no padding) to produce /// a 43-character verifier per RFC 7636. -fn generate_code_verifier() -> String { +pub(crate) fn generate_code_verifier() -> String { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; let bytes: [u8; 32] = rand::rng().random(); @@ -85,7 +86,7 @@ fn generate_code_verifier() -> String { /// Derive the S256 code challenge from a code verifier per RFC 7636. /// /// `code_challenge = BASE64URL(SHA256(code_verifier))` -fn derive_code_challenge(verifier: &str) -> String { +pub(crate) fn derive_code_challenge(verifier: &str) -> String { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use sha2::{Digest, Sha256}; @@ -304,3 +305,117 @@ pub async fn set_login_session(session: Session, data: UserStateInner) -> Result .await .map_err(|e| Error::StateError(format!("session insert failed: {e}"))) } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + use pretty_assertions::assert_eq; + + // ----------------------------------------------------------------------- + // generate_state() + // ----------------------------------------------------------------------- + + #[test] + fn generate_state_length_is_64() { + let state = generate_state(); + assert_eq!(state.len(), 64); + } + + #[test] + fn generate_state_chars_are_hex() { + let state = generate_state(); + assert!(state.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn generate_state_two_calls_differ() { + let a = generate_state(); + let b = generate_state(); + assert_ne!(a, b); + } + + // ----------------------------------------------------------------------- + // generate_code_verifier() + // ----------------------------------------------------------------------- + + #[test] + fn code_verifier_length_is_43() { + let verifier = generate_code_verifier(); + assert_eq!(verifier.len(), 43); + } + + #[test] + fn code_verifier_chars_are_url_safe_base64() { + let verifier = generate_code_verifier(); + // URL-safe base64 without padding uses [A-Za-z0-9_-] + assert!(verifier + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')); + } + + // ----------------------------------------------------------------------- + // derive_code_challenge() + // ----------------------------------------------------------------------- + + #[test] + fn code_challenge_deterministic() { + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + let a = derive_code_challenge(verifier); + let b = derive_code_challenge(verifier); + assert_eq!(a, b); + } + + #[test] + fn code_challenge_rfc7636_test_vector() { + // RFC 7636 Appendix B test vector: + // verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + // expected challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + let challenge = derive_code_challenge(verifier); + assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + } + + // ----------------------------------------------------------------------- + // PendingOAuthStore + // ----------------------------------------------------------------------- + + #[test] + fn pending_store_insert_and_take() { + let store = PendingOAuthStore::default(); + store.insert( + "state-1".into(), + PendingOAuthEntry { + redirect_url: Some("/dashboard".into()), + code_verifier: "verifier-1".into(), + }, + ); + let entry = store.take("state-1"); + assert!(entry.is_some()); + let entry = entry.unwrap(); + assert_eq!(entry.redirect_url, Some("/dashboard".into())); + assert_eq!(entry.code_verifier, "verifier-1"); + } + + #[test] + fn pending_store_take_removes_entry() { + let store = PendingOAuthStore::default(); + store.insert( + "state-2".into(), + PendingOAuthEntry { + redirect_url: None, + code_verifier: "v2".into(), + }, + ); + let _ = store.take("state-2"); + // Second take should return None since the entry was removed. + assert!(store.take("state-2").is_none()); + } + + #[test] + fn pending_store_take_unknown_returns_none() { + let store = PendingOAuthStore::default(); + assert!(store.take("nonexistent").is_none()); + } +} diff --git a/src/infrastructure/chat.rs b/src/infrastructure/chat.rs index 03c3015..5b5e99a 100644 --- a/src/infrastructure/chat.rs +++ b/src/infrastructure/chat.rs @@ -440,7 +440,12 @@ pub async fn chat_complete( let session = doc_to_chat_session(&session_doc); // Resolve provider URL and model - let (base_url, model) = resolve_provider_url(&state, &session.provider, &session.model); + let (base_url, model) = resolve_provider_url( + &state.services.ollama_url, + &state.services.ollama_model, + &session.provider, + &session.model, + ); // Parse messages from JSON let chat_msgs: Vec = serde_json::from_str(&messages_json) @@ -480,10 +485,22 @@ pub async fn chat_complete( .ok_or_else(|| ServerFnError::new("empty LLM response")) } -/// Resolve the base URL for a provider, falling back to server defaults. +/// Resolve the base URL for a provider, falling back to Ollama defaults. +/// +/// # Arguments +/// +/// * `ollama_url` - Default Ollama base URL from config +/// * `ollama_model` - Default Ollama model from config +/// * `provider` - Provider name (e.g. "openai", "anthropic", "huggingface") +/// * `model` - Model ID (may be empty for Ollama default) +/// +/// # Returns +/// +/// A `(base_url, model)` tuple resolved for the given provider. #[cfg(feature = "server")] -fn resolve_provider_url( - state: &crate::infrastructure::ServerState, +pub(crate) fn resolve_provider_url( + ollama_url: &str, + ollama_model: &str, provider: &str, model: &str, ) -> (String, String) { @@ -496,12 +513,229 @@ fn resolve_provider_url( ), // Default to Ollama _ => ( - state.services.ollama_url.clone(), + ollama_url.to_string(), if model.is_empty() { - state.services.ollama_model.clone() + ollama_model.to_string() } else { model.to_string() }, ), } } + +#[cfg(test)] +mod tests { + // ----------------------------------------------------------------------- + // BSON document conversion tests (server feature required) + // ----------------------------------------------------------------------- + + #[cfg(feature = "server")] + mod server_tests { + use super::super::{doc_to_chat_message, doc_to_chat_session, resolve_provider_url}; + use crate::models::{ChatNamespace, ChatRole}; + use mongodb::bson::{doc, oid::ObjectId, Document}; + use pretty_assertions::assert_eq; + + // -- doc_to_chat_session -- + + fn sample_session_doc() -> (ObjectId, Document) { + let oid = ObjectId::new(); + let doc = doc! { + "_id": oid, + "user_sub": "user-42", + "title": "Test Session", + "namespace": "News", + "provider": "openai", + "model": "gpt-4", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-02T00:00:00Z", + "article_url": "https://example.com/article", + }; + (oid, doc) + } + + #[test] + fn doc_to_chat_session_extracts_id_as_hex() { + let (oid, doc) = sample_session_doc(); + let session = doc_to_chat_session(&doc); + assert_eq!(session.id, oid.to_hex()); + } + + #[test] + fn doc_to_chat_session_maps_news_namespace() { + let (_, doc) = sample_session_doc(); + let session = doc_to_chat_session(&doc); + assert_eq!(session.namespace, ChatNamespace::News); + } + + #[test] + fn doc_to_chat_session_defaults_to_general_for_unknown() { + let mut doc = sample_session_doc().1; + doc.insert("namespace", "SomethingElse"); + let session = doc_to_chat_session(&doc); + assert_eq!(session.namespace, ChatNamespace::General); + } + + #[test] + fn doc_to_chat_session_extracts_all_string_fields() { + let (_, doc) = sample_session_doc(); + let session = doc_to_chat_session(&doc); + assert_eq!(session.user_sub, "user-42"); + assert_eq!(session.title, "Test Session"); + assert_eq!(session.provider, "openai"); + assert_eq!(session.model, "gpt-4"); + assert_eq!(session.created_at, "2025-01-01T00:00:00Z"); + assert_eq!(session.updated_at, "2025-01-02T00:00:00Z"); + } + + #[test] + fn doc_to_chat_session_handles_missing_article_url() { + let oid = ObjectId::new(); + let doc = doc! { + "_id": oid, + "user_sub": "u", + "title": "t", + "provider": "ollama", + "model": "m", + "created_at": "c", + "updated_at": "u", + }; + let session = doc_to_chat_session(&doc); + assert_eq!(session.article_url, None); + } + + #[test] + fn doc_to_chat_session_filters_empty_article_url() { + let oid = ObjectId::new(); + let doc = doc! { + "_id": oid, + "user_sub": "u", + "title": "t", + "namespace": "News", + "provider": "ollama", + "model": "m", + "created_at": "c", + "updated_at": "u", + "article_url": "", + }; + let session = doc_to_chat_session(&doc); + assert_eq!(session.article_url, None); + } + + // -- doc_to_chat_message -- + + fn sample_message_doc() -> (ObjectId, Document) { + let oid = ObjectId::new(); + let doc = doc! { + "_id": oid, + "session_id": "sess-1", + "role": "Assistant", + "content": "Hello there!", + "timestamp": "2025-01-01T12:00:00Z", + }; + (oid, doc) + } + + #[test] + fn doc_to_chat_message_extracts_id_as_hex() { + let (oid, doc) = sample_message_doc(); + let msg = doc_to_chat_message(&doc); + assert_eq!(msg.id, oid.to_hex()); + } + + #[test] + fn doc_to_chat_message_maps_assistant_role() { + let (_, doc) = sample_message_doc(); + let msg = doc_to_chat_message(&doc); + assert_eq!(msg.role, ChatRole::Assistant); + } + + #[test] + fn doc_to_chat_message_maps_system_role() { + let mut doc = sample_message_doc().1; + doc.insert("role", "System"); + let msg = doc_to_chat_message(&doc); + assert_eq!(msg.role, ChatRole::System); + } + + #[test] + fn doc_to_chat_message_defaults_to_user_for_unknown() { + let mut doc = sample_message_doc().1; + doc.insert("role", "SomethingElse"); + let msg = doc_to_chat_message(&doc); + assert_eq!(msg.role, ChatRole::User); + } + + #[test] + fn doc_to_chat_message_extracts_content_and_timestamp() { + let (_, doc) = sample_message_doc(); + let msg = doc_to_chat_message(&doc); + assert_eq!(msg.content, "Hello there!"); + assert_eq!(msg.timestamp, "2025-01-01T12:00:00Z"); + assert_eq!(msg.session_id, "sess-1"); + } + + #[test] + fn doc_to_chat_message_attachments_always_empty() { + let (_, doc) = sample_message_doc(); + let msg = doc_to_chat_message(&doc); + assert!(msg.attachments.is_empty()); + } + + // -- resolve_provider_url -- + + const TEST_OLLAMA_URL: &str = "http://localhost:11434"; + const TEST_OLLAMA_MODEL: &str = "llama3.1:8b"; + + #[test] + fn resolve_openai_returns_api_openai() { + let (url, model) = + resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "openai", "gpt-4o"); + assert_eq!(url, "https://api.openai.com"); + assert_eq!(model, "gpt-4o"); + } + + #[test] + fn resolve_anthropic_returns_api_anthropic() { + let (url, model) = resolve_provider_url( + TEST_OLLAMA_URL, + TEST_OLLAMA_MODEL, + "anthropic", + "claude-3-opus", + ); + assert_eq!(url, "https://api.anthropic.com"); + assert_eq!(model, "claude-3-opus"); + } + + #[test] + fn resolve_huggingface_returns_model_url() { + let (url, model) = resolve_provider_url( + TEST_OLLAMA_URL, + TEST_OLLAMA_MODEL, + "huggingface", + "meta-llama/Llama-2-7b", + ); + assert_eq!( + url, + "https://api-inference.huggingface.co/models/meta-llama/Llama-2-7b" + ); + assert_eq!(model, "meta-llama/Llama-2-7b"); + } + + #[test] + fn resolve_unknown_defaults_to_ollama() { + let (url, model) = + resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "mistral:7b"); + assert_eq!(url, TEST_OLLAMA_URL); + assert_eq!(model, "mistral:7b"); + } + + #[test] + fn resolve_empty_model_falls_back_to_server_default() { + let (url, model) = + resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", ""); + assert_eq!(url, TEST_OLLAMA_URL); + assert_eq!(model, TEST_OLLAMA_MODEL); + } + } +} diff --git a/src/infrastructure/config.rs b/src/infrastructure/config.rs index c068aa7..3ce3ac5 100644 --- a/src/infrastructure/config.rs +++ b/src/infrastructure/config.rs @@ -251,3 +251,160 @@ impl LlmProvidersConfig { Ok(Self { providers }) } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + use pretty_assertions::assert_eq; + use serial_test::serial; + + // ----------------------------------------------------------------------- + // KeycloakConfig endpoint methods (no env vars needed) + // ----------------------------------------------------------------------- + + fn sample_keycloak() -> KeycloakConfig { + KeycloakConfig { + url: "https://auth.example.com".into(), + realm: "myrealm".into(), + client_id: "dashboard".into(), + redirect_uri: "https://app.example.com/callback".into(), + app_url: "https://app.example.com".into(), + admin_client_id: String::new(), + admin_client_secret: SecretString::from(String::new()), + } + } + + #[test] + fn keycloak_auth_endpoint() { + let kc = sample_keycloak(); + assert_eq!( + kc.auth_endpoint(), + "https://auth.example.com/realms/myrealm/protocol/openid-connect/auth" + ); + } + + #[test] + fn keycloak_token_endpoint() { + let kc = sample_keycloak(); + assert_eq!( + kc.token_endpoint(), + "https://auth.example.com/realms/myrealm/protocol/openid-connect/token" + ); + } + + #[test] + fn keycloak_userinfo_endpoint() { + let kc = sample_keycloak(); + assert_eq!( + kc.userinfo_endpoint(), + "https://auth.example.com/realms/myrealm/protocol/openid-connect/userinfo" + ); + } + + #[test] + fn keycloak_logout_endpoint() { + let kc = sample_keycloak(); + assert_eq!( + kc.logout_endpoint(), + "https://auth.example.com/realms/myrealm/protocol/openid-connect/logout" + ); + } + + // ----------------------------------------------------------------------- + // LlmProvidersConfig::from_env() + // ----------------------------------------------------------------------- + + #[test] + #[serial] + fn llm_providers_empty_string() { + std::env::set_var("LLM_PROVIDERS", ""); + let cfg = LlmProvidersConfig::from_env().unwrap(); + assert!(cfg.providers.is_empty()); + std::env::remove_var("LLM_PROVIDERS"); + } + + #[test] + #[serial] + fn llm_providers_single() { + std::env::set_var("LLM_PROVIDERS", "ollama"); + let cfg = LlmProvidersConfig::from_env().unwrap(); + assert_eq!(cfg.providers, vec!["ollama"]); + std::env::remove_var("LLM_PROVIDERS"); + } + + #[test] + #[serial] + fn llm_providers_multiple() { + std::env::set_var("LLM_PROVIDERS", "ollama,openai,anthropic"); + let cfg = LlmProvidersConfig::from_env().unwrap(); + assert_eq!(cfg.providers, vec!["ollama", "openai", "anthropic"]); + std::env::remove_var("LLM_PROVIDERS"); + } + + #[test] + #[serial] + fn llm_providers_trims_whitespace() { + std::env::set_var("LLM_PROVIDERS", " ollama , openai "); + let cfg = LlmProvidersConfig::from_env().unwrap(); + assert_eq!(cfg.providers, vec!["ollama", "openai"]); + std::env::remove_var("LLM_PROVIDERS"); + } + + #[test] + #[serial] + fn llm_providers_filters_empty_entries() { + std::env::set_var("LLM_PROVIDERS", "ollama,,openai,"); + let cfg = LlmProvidersConfig::from_env().unwrap(); + assert_eq!(cfg.providers, vec!["ollama", "openai"]); + std::env::remove_var("LLM_PROVIDERS"); + } + + // ----------------------------------------------------------------------- + // ServiceUrls::from_env() defaults + // ----------------------------------------------------------------------- + + #[test] + #[serial] + fn service_urls_default_ollama_url() { + std::env::remove_var("OLLAMA_URL"); + let svc = ServiceUrls::from_env().unwrap(); + assert_eq!(svc.ollama_url, "http://localhost:11434"); + } + + #[test] + #[serial] + fn service_urls_default_ollama_model() { + std::env::remove_var("OLLAMA_MODEL"); + let svc = ServiceUrls::from_env().unwrap(); + assert_eq!(svc.ollama_model, "llama3.1:8b"); + } + + #[test] + #[serial] + fn service_urls_default_searxng_url() { + std::env::remove_var("SEARXNG_URL"); + let svc = ServiceUrls::from_env().unwrap(); + assert_eq!(svc.searxng_url, "http://localhost:8888"); + } + + #[test] + #[serial] + fn service_urls_custom_ollama_url() { + std::env::set_var("OLLAMA_URL", "http://gpu-host:11434"); + let svc = ServiceUrls::from_env().unwrap(); + assert_eq!(svc.ollama_url, "http://gpu-host:11434"); + std::env::remove_var("OLLAMA_URL"); + } + + #[test] + #[serial] + fn required_env_missing_returns_config_error() { + std::env::remove_var("__TEST_REQUIRED_MISSING__"); + let result = required_env("__TEST_REQUIRED_MISSING__"); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("__TEST_REQUIRED_MISSING__")); + } +} diff --git a/src/infrastructure/error.rs b/src/infrastructure/error.rs index 65b2d51..838d20a 100644 --- a/src/infrastructure/error.rs +++ b/src/infrastructure/error.rs @@ -41,3 +41,53 @@ impl IntoResponse for Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + use axum::response::IntoResponse; + use pretty_assertions::assert_eq; + + #[test] + fn state_error_display() { + let err = Error::StateError("bad state".into()); + assert_eq!(err.to_string(), "bad state"); + } + + #[test] + fn database_error_display() { + let err = Error::DatabaseError("connection lost".into()); + assert_eq!(err.to_string(), "database error: connection lost"); + } + + #[test] + fn config_error_display() { + let err = Error::ConfigError("missing var".into()); + assert_eq!(err.to_string(), "configuration error: missing var"); + } + + #[test] + fn state_error_into_response_500() { + let resp = Error::StateError("oops".into()).into_response(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn database_error_into_response_503() { + let resp = Error::DatabaseError("down".into()).into_response(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn config_error_into_response_500() { + let resp = Error::ConfigError("bad cfg".into()).into_response(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn io_error_into_response_500() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found"); + let resp = Error::IoError(io_err).into_response(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +} diff --git a/src/infrastructure/llm.rs b/src/infrastructure/llm.rs index 07379c0..b68e2ab 100644 --- a/src/infrastructure/llm.rs +++ b/src/infrastructure/llm.rs @@ -72,7 +72,25 @@ mod inner { } let html = resp.text().await.ok()?; - let document = scraper::Html::parse_document(&html); + parse_article_html(&html) + } + + /// Parse article text from raw HTML without any network I/O. + /// + /// Uses a tiered extraction strategy: + /// 1. Try content within `
`, `
`, or `[role="main"]` + /// 2. Fall back to all `

` tags outside excluded containers + /// + /// # Arguments + /// + /// * `html` - Raw HTML string to parse + /// + /// # Returns + /// + /// The extracted text, or `None` if extraction yields < 100 chars. + /// Output is capped at 8000 characters. + pub(crate) fn parse_article_html(html: &str) -> Option { + let document = scraper::Html::parse_document(html); // Strategy 1: Extract from semantic article containers. // Most news sites wrap the main content in

,
, @@ -134,7 +152,7 @@ mod inner { } /// Sum the total character length of all collected text parts. - fn joined_len(parts: &[String]) -> usize { + pub(crate) fn joined_len(parts: &[String]) -> usize { parts.iter().map(|s| s.len()).sum() } } @@ -325,3 +343,150 @@ pub async fn chat_followup( .map(|choice| choice.message.content.clone()) .ok_or_else(|| ServerFnError::new("Empty response from Ollama")) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + // ----------------------------------------------------------------------- + // FollowUpMessage serde tests + // ----------------------------------------------------------------------- + + #[test] + fn followup_message_serde_round_trip() { + let msg = FollowUpMessage { + role: "assistant".into(), + content: "Here is my answer.".into(), + }; + let json = serde_json::to_string(&msg).expect("serialize FollowUpMessage"); + let back: FollowUpMessage = + serde_json::from_str(&json).expect("deserialize FollowUpMessage"); + assert_eq!(msg, back); + } + + #[test] + fn followup_message_deserialize_from_json_literal() { + let json = r#"{"role":"system","content":"You are helpful."}"#; + let msg: FollowUpMessage = serde_json::from_str(json).expect("deserialize literal"); + assert_eq!(msg.role, "system"); + assert_eq!(msg.content, "You are helpful."); + } + + // ----------------------------------------------------------------------- + // joined_len and parse_article_html tests (server feature required) + // ----------------------------------------------------------------------- + + #[cfg(feature = "server")] + mod server_tests { + use super::super::inner::{joined_len, parse_article_html}; + use pretty_assertions::assert_eq; + + #[test] + fn joined_len_empty_input() { + assert_eq!(joined_len(&[]), 0); + } + + #[test] + fn joined_len_sums_correctly() { + let parts = vec!["abc".into(), "de".into(), "fghij".into()]; + assert_eq!(joined_len(&parts), 10); + } + + // ------------------------------------------------------------------- + // parse_article_html tests + // ------------------------------------------------------------------- + + // Helper: generate a string of given length from a repeated word. + fn lorem(len: usize) -> String { + "Lorem ipsum dolor sit amet consectetur adipiscing elit " + .repeat((len / 55) + 1) + .chars() + .take(len) + .collect() + } + + #[test] + fn article_tag_extracts_text() { + let body = lorem(250); + let html = format!("

{body}

"); + let result = parse_article_html(&html); + assert!(result.is_some(), "expected Some for article tag"); + assert!(result.unwrap().contains("Lorem")); + } + + #[test] + fn main_tag_extracts_text() { + let body = lorem(250); + let html = format!("

{body}

"); + let result = parse_article_html(&html); + assert!(result.is_some(), "expected Some for main tag"); + } + + #[test] + fn fallback_to_p_tags_when_article_main_yield_little() { + // No
/
, so falls back to

tags + let body = lorem(250); + let html = format!("

{body}

"); + let result = parse_article_html(&html); + assert!(result.is_some(), "expected fallback to

tags"); + } + + #[test] + fn excludes_nav_footer_aside_content() { + // Content only inside excluded containers -- should be excluded + let body = lorem(250); + let html = format!( + "\ +

\ +

{body}

\ + \ + " + ); + let result = parse_article_html(&html); + assert!(result.is_none(), "expected None for excluded-only content"); + } + + #[test] + fn returns_none_when_text_too_short() { + let html = "

Short.

"; + let result = parse_article_html(html); + assert!(result.is_none(), "expected None for short text"); + } + + #[test] + fn truncates_at_8000_chars() { + let body = lorem(10000); + let html = format!("

{body}

"); + let result = parse_article_html(&html).expect("expected Some"); + assert!( + result.len() <= 8000, + "expected <= 8000 chars, got {}", + result.len() + ); + } + + #[test] + fn skips_fragments_under_30_chars() { + // Only fragments < 30 chars -- should yield None + let html = "
\ +

Short frag one

\ +

Another tiny bit

\ +
"; + let result = parse_article_html(html); + assert!(result.is_none(), "expected None for tiny fragments"); + } + + #[test] + fn extracts_from_role_main_attribute() { + let body = lorem(250); + let html = format!( + "\ +

{body}

\ + " + ); + let result = parse_article_html(&html); + assert!(result.is_some(), "expected Some for role=main"); + } + } +} diff --git a/src/infrastructure/provider_client.rs b/src/infrastructure/provider_client.rs index ce915b1..804eba6 100644 --- a/src/infrastructure/provider_client.rs +++ b/src/infrastructure/provider_client.rs @@ -146,3 +146,30 @@ pub async fn send_chat_request( } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn provider_message_serde_round_trip() { + let msg = ProviderMessage { + role: "assistant".into(), + content: "Hello, world!".into(), + }; + let json = serde_json::to_string(&msg).expect("serialize ProviderMessage"); + let back: ProviderMessage = + serde_json::from_str(&json).expect("deserialize ProviderMessage"); + assert_eq!(msg.role, back.role); + assert_eq!(msg.content, back.content); + } + + #[test] + fn provider_message_deserialize_from_json_literal() { + let json = r#"{"role":"user","content":"What is Rust?"}"#; + let msg: ProviderMessage = serde_json::from_str(json).expect("deserialize from literal"); + assert_eq!(msg.role, "user"); + assert_eq!(msg.content, "What is Rust?"); + } +} diff --git a/src/infrastructure/searxng.rs b/src/infrastructure/searxng.rs index 713e67e..4e808e4 100644 --- a/src/infrastructure/searxng.rs +++ b/src/infrastructure/searxng.rs @@ -5,13 +5,13 @@ use dioxus::prelude::*; // The #[server] macro generates a client stub for the web build that // sends a network request instead of executing this function body. #[cfg(feature = "server")] -mod inner { +pub(crate) mod inner { use serde::Deserialize; use std::collections::HashSet; /// Individual result from the SearXNG search API. #[derive(Debug, Deserialize)] - pub(super) struct SearxngResult { + pub(crate) struct SearxngResult { pub title: String, pub url: String, pub content: Option, @@ -25,7 +25,7 @@ mod inner { /// Top-level response from the SearXNG search API. #[derive(Debug, Deserialize)] - pub(super) struct SearxngResponse { + pub(crate) struct SearxngResponse { pub results: Vec, } @@ -40,7 +40,7 @@ mod inner { /// # Returns /// /// The domain host or a fallback "Web" string - pub(super) fn extract_source(url_str: &str) -> String { + pub(crate) fn extract_source(url_str: &str) -> String { url::Url::parse(url_str) .ok() .and_then(|u| u.host_str().map(String::from)) @@ -64,7 +64,7 @@ mod inner { /// # Returns /// /// Filtered, deduplicated, and ranked results - pub(super) fn rank_and_deduplicate( + pub(crate) fn rank_and_deduplicate( mut results: Vec, max_results: usize, ) -> Vec { @@ -285,3 +285,166 @@ pub async fn get_trending_topics() -> Result, ServerFnError> { Ok(topics) } + +#[cfg(all(test, feature = "server"))] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::inner::*; + use pretty_assertions::assert_eq; + + // ----------------------------------------------------------------------- + // extract_source() + // ----------------------------------------------------------------------- + + #[test] + fn extract_source_strips_www() { + assert_eq!( + extract_source("https://www.example.com/page"), + "example.com" + ); + } + + #[test] + fn extract_source_returns_domain() { + assert_eq!( + extract_source("https://techcrunch.com/article"), + "techcrunch.com" + ); + } + + #[test] + fn extract_source_invalid_url_returns_web() { + assert_eq!(extract_source("not-a-url"), "Web"); + } + + #[test] + fn extract_source_no_scheme_returns_web() { + // url::Url::parse requires a scheme; bare domain fails + assert_eq!(extract_source("example.com/path"), "Web"); + } + + // ----------------------------------------------------------------------- + // rank_and_deduplicate() + // ----------------------------------------------------------------------- + + fn make_result(url: &str, content: &str, score: f64) -> SearxngResult { + SearxngResult { + title: "Title".into(), + url: url.into(), + content: if content.is_empty() { + None + } else { + Some(content.into()) + }, + published_date: None, + thumbnail: None, + score, + } + } + + #[test] + fn rank_filters_empty_content() { + let results = vec![ + make_result("https://a.com", "", 10.0), + make_result( + "https://b.com", + "This is meaningful content that passes the length filter", + 5.0, + ), + ]; + let ranked = rank_and_deduplicate(results, 10); + assert_eq!(ranked.len(), 1); + assert_eq!(ranked[0].url, "https://b.com"); + } + + #[test] + fn rank_filters_short_content() { + let results = vec![ + make_result("https://a.com", "short", 10.0), + make_result( + "https://b.com", + "This content is long enough to pass the 20-char filter threshold", + 5.0, + ), + ]; + let ranked = rank_and_deduplicate(results, 10); + assert_eq!(ranked.len(), 1); + } + + #[test] + fn rank_deduplicates_by_domain_keeps_highest() { + let results = vec![ + make_result( + "https://example.com/page1", + "First result with enough content here for the filter", + 3.0, + ), + make_result( + "https://example.com/page2", + "Second result with enough content here for the filter", + 8.0, + ), + ]; + let ranked = rank_and_deduplicate(results, 10); + assert_eq!(ranked.len(), 1); + // Should keep the highest-scored one (page2 with score 8.0) + assert_eq!(ranked[0].url, "https://example.com/page2"); + } + + #[test] + fn rank_sorts_by_score_descending() { + let results = vec![ + make_result( + "https://a.com/p", + "Content A that is long enough to pass the filter check", + 1.0, + ), + make_result( + "https://b.com/p", + "Content B that is long enough to pass the filter check", + 5.0, + ), + make_result( + "https://c.com/p", + "Content C that is long enough to pass the filter check", + 3.0, + ), + ]; + let ranked = rank_and_deduplicate(results, 10); + assert_eq!(ranked.len(), 3); + assert!(ranked[0].score >= ranked[1].score); + assert!(ranked[1].score >= ranked[2].score); + } + + #[test] + fn rank_truncates_to_max_results() { + let results: Vec<_> = (0..20) + .map(|i| { + make_result( + &format!("https://site{i}.com/page"), + &format!("Content for site {i} that is long enough to pass the filter"), + i as f64, + ) + }) + .collect(); + let ranked = rank_and_deduplicate(results, 5); + assert_eq!(ranked.len(), 5); + } + + #[test] + fn rank_empty_input_returns_empty() { + let ranked = rank_and_deduplicate(vec![], 10); + assert!(ranked.is_empty()); + } + + #[test] + fn rank_all_filtered_returns_empty() { + let results = vec![ + make_result("https://a.com", "", 10.0), + make_result("https://b.com", "too short", 5.0), + ]; + let ranked = rank_and_deduplicate(results, 10); + assert!(ranked.is_empty()); + } +} diff --git a/src/infrastructure/state.rs b/src/infrastructure/state.rs index d6c2bc1..9d3a75e 100644 --- a/src/infrastructure/state.rs +++ b/src/infrastructure/state.rs @@ -44,3 +44,91 @@ pub struct User { /// Avatar / profile picture URL. pub avatar_url: String, } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn user_state_inner_default_has_empty_strings() { + let inner = UserStateInner::default(); + assert_eq!(inner.sub, ""); + assert_eq!(inner.access_token, ""); + assert_eq!(inner.refresh_token, ""); + assert_eq!(inner.user.email, ""); + assert_eq!(inner.user.name, ""); + assert_eq!(inner.user.avatar_url, ""); + } + + #[test] + fn user_default_has_empty_strings() { + let user = User::default(); + assert_eq!(user.email, ""); + assert_eq!(user.name, ""); + assert_eq!(user.avatar_url, ""); + } + + #[test] + fn user_state_inner_serde_round_trip() { + let inner = UserStateInner { + sub: "user-123".into(), + access_token: "tok-abc".into(), + refresh_token: "ref-xyz".into(), + user: User { + email: "a@b.com".into(), + name: "Alice".into(), + avatar_url: "https://img.example.com/a.png".into(), + }, + }; + let json = serde_json::to_string(&inner).expect("serialize UserStateInner"); + let back: UserStateInner = serde_json::from_str(&json).expect("deserialize UserStateInner"); + assert_eq!(inner.sub, back.sub); + assert_eq!(inner.access_token, back.access_token); + assert_eq!(inner.refresh_token, back.refresh_token); + assert_eq!(inner.user.email, back.user.email); + assert_eq!(inner.user.name, back.user.name); + assert_eq!(inner.user.avatar_url, back.user.avatar_url); + } + + #[test] + fn user_state_from_inner_and_deref() { + let inner = UserStateInner { + sub: "sub-1".into(), + access_token: "at".into(), + refresh_token: "rt".into(), + user: User { + email: "e@e.com".into(), + name: "Eve".into(), + avatar_url: "".into(), + }, + }; + let state = UserState::from(inner); + // Deref should give access to inner fields + assert_eq!(state.sub, "sub-1"); + assert_eq!(state.user.name, "Eve"); + } + + #[test] + fn user_serde_round_trip() { + let user = User { + email: "bob@test.com".into(), + name: "Bob".into(), + avatar_url: "https://avatars.io/bob".into(), + }; + let json = serde_json::to_string(&user).expect("serialize User"); + let back: User = serde_json::from_str(&json).expect("deserialize User"); + assert_eq!(user.email, back.email); + assert_eq!(user.name, back.name); + assert_eq!(user.avatar_url, back.avatar_url); + } + + #[test] + fn user_state_clone_is_cheap() { + let inner = UserStateInner::default(); + let state = UserState::from(inner); + let cloned = state.clone(); + // Both point to the same Arc allocation + assert_eq!(state.sub, cloned.sub); + } +} diff --git a/src/models/chat.rs b/src/models/chat.rs index e6f6134..aa869de 100644 --- a/src/models/chat.rs +++ b/src/models/chat.rs @@ -105,3 +105,163 @@ pub struct ChatMessage { pub attachments: Vec, pub timestamp: String, } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn chat_namespace_default_is_general() { + assert_eq!(ChatNamespace::default(), ChatNamespace::General); + } + + #[test] + fn chat_role_serde_round_trip() { + for role in [ChatRole::User, ChatRole::Assistant, ChatRole::System] { + let json = + serde_json::to_string(&role).unwrap_or_else(|_| panic!("serialize {:?}", role)); + let back: ChatRole = + serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", role)); + assert_eq!(role, back); + } + } + + #[test] + fn chat_namespace_serde_round_trip() { + for ns in [ChatNamespace::General, ChatNamespace::News] { + let json = serde_json::to_string(&ns).unwrap_or_else(|_| panic!("serialize {:?}", ns)); + let back: ChatNamespace = + serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", ns)); + assert_eq!(ns, back); + } + } + + #[test] + fn attachment_kind_serde_round_trip() { + for kind in [ + AttachmentKind::Image, + AttachmentKind::Document, + AttachmentKind::Code, + ] { + let json = + serde_json::to_string(&kind).unwrap_or_else(|_| panic!("serialize {:?}", kind)); + let back: AttachmentKind = + serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", kind)); + assert_eq!(kind, back); + } + } + + #[test] + fn attachment_serde_round_trip() { + let att = Attachment { + name: "photo.png".into(), + kind: AttachmentKind::Image, + size_bytes: 2048, + }; + let json = serde_json::to_string(&att).expect("serialize Attachment"); + let back: Attachment = serde_json::from_str(&json).expect("deserialize Attachment"); + assert_eq!(att, back); + } + + #[test] + fn chat_session_serde_round_trip() { + let session = ChatSession { + id: "abc123".into(), + user_sub: "user-1".into(), + title: "Test Chat".into(), + namespace: ChatNamespace::General, + provider: "ollama".into(), + model: "llama3.1:8b".into(), + created_at: "2025-01-01T00:00:00Z".into(), + updated_at: "2025-01-01T01:00:00Z".into(), + article_url: None, + }; + let json = serde_json::to_string(&session).expect("serialize ChatSession"); + let back: ChatSession = serde_json::from_str(&json).expect("deserialize ChatSession"); + assert_eq!(session, back); + } + + #[test] + fn chat_session_id_alias_deserialization() { + // MongoDB returns `_id` instead of `id` + let json = r#"{ + "_id": "mongo-id", + "user_sub": "u1", + "title": "t", + "provider": "ollama", + "model": "m", + "created_at": "2025-01-01", + "updated_at": "2025-01-01" + }"#; + let session: ChatSession = serde_json::from_str(json).expect("deserialize with _id"); + assert_eq!(session.id, "mongo-id"); + } + + #[test] + fn chat_session_empty_id_skips_serialization() { + let session = ChatSession { + id: String::new(), + user_sub: "u1".into(), + title: "t".into(), + namespace: ChatNamespace::default(), + provider: "ollama".into(), + model: "m".into(), + created_at: "2025-01-01".into(), + updated_at: "2025-01-01".into(), + article_url: None, + }; + let json = serde_json::to_string(&session).expect("serialize"); + // `id` field should be absent when empty due to skip_serializing_if + assert!(!json.contains("\"id\"")); + } + + #[test] + fn chat_session_none_article_url_skips_serialization() { + let session = ChatSession { + id: "s1".into(), + user_sub: "u1".into(), + title: "t".into(), + namespace: ChatNamespace::default(), + provider: "ollama".into(), + model: "m".into(), + created_at: "2025-01-01".into(), + updated_at: "2025-01-01".into(), + article_url: None, + }; + let json = serde_json::to_string(&session).expect("serialize"); + assert!(!json.contains("article_url")); + } + + #[test] + fn chat_message_serde_round_trip() { + let msg = ChatMessage { + id: "msg-1".into(), + session_id: "s1".into(), + role: ChatRole::User, + content: "Hello AI".into(), + attachments: vec![Attachment { + name: "doc.pdf".into(), + kind: AttachmentKind::Document, + size_bytes: 4096, + }], + timestamp: "2025-01-01T00:00:00Z".into(), + }; + let json = serde_json::to_string(&msg).expect("serialize ChatMessage"); + let back: ChatMessage = serde_json::from_str(&json).expect("deserialize ChatMessage"); + assert_eq!(msg, back); + } + + #[test] + fn chat_message_id_alias_deserialization() { + let json = r#"{ + "_id": "mongo-msg-id", + "session_id": "s1", + "role": "User", + "content": "hi", + "timestamp": "2025-01-01" + }"#; + let msg: ChatMessage = serde_json::from_str(json).expect("deserialize with _id"); + assert_eq!(msg.id, "mongo-msg-id"); + } +} diff --git a/src/models/developer.rs b/src/models/developer.rs index 1138e96..9ba530d 100644 --- a/src/models/developer.rs +++ b/src/models/developer.rs @@ -45,3 +45,63 @@ pub struct AnalyticsMetric { pub value: String, pub change_pct: f64, } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn agent_entry_serde_round_trip() { + let agent = AgentEntry { + id: "a1".into(), + name: "RAG Agent".into(), + description: "Retrieval-augmented generation".into(), + status: "running".into(), + }; + let json = serde_json::to_string(&agent).expect("serialize AgentEntry"); + let back: AgentEntry = serde_json::from_str(&json).expect("deserialize AgentEntry"); + assert_eq!(agent, back); + } + + #[test] + fn flow_entry_serde_round_trip() { + let flow = FlowEntry { + id: "f1".into(), + name: "Data Pipeline".into(), + node_count: 5, + last_run: Some("2025-06-01T12:00:00Z".into()), + }; + let json = serde_json::to_string(&flow).expect("serialize FlowEntry"); + let back: FlowEntry = serde_json::from_str(&json).expect("deserialize FlowEntry"); + assert_eq!(flow, back); + } + + #[test] + fn flow_entry_with_none_last_run() { + let flow = FlowEntry { + id: "f2".into(), + name: "New Flow".into(), + node_count: 0, + last_run: None, + }; + let json = serde_json::to_string(&flow).expect("serialize"); + let back: FlowEntry = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(flow, back); + assert_eq!(back.last_run, None); + } + + #[test] + fn analytics_metric_negative_change_pct() { + let metric = AnalyticsMetric { + label: "Latency".into(), + value: "120ms".into(), + change_pct: -15.5, + }; + let json = serde_json::to_string(&metric).expect("serialize AnalyticsMetric"); + let back: AnalyticsMetric = + serde_json::from_str(&json).expect("deserialize AnalyticsMetric"); + assert_eq!(metric, back); + assert!(back.change_pct < 0.0); + } +} diff --git a/src/models/news.rs b/src/models/news.rs index 833920a..ffa3930 100644 --- a/src/models/news.rs +++ b/src/models/news.rs @@ -23,3 +23,61 @@ pub struct NewsCard { pub thumbnail_url: Option, pub published_at: String, } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn news_card_serde_round_trip() { + let card = NewsCard { + title: "AI Breakthrough".into(), + source: "techcrunch.com".into(), + summary: "New model released".into(), + content: "Full article content here".into(), + category: "AI".into(), + url: "https://example.com/article".into(), + thumbnail_url: Some("https://example.com/thumb.jpg".into()), + published_at: "2025-06-01".into(), + }; + let json = serde_json::to_string(&card).expect("serialize NewsCard"); + let back: NewsCard = serde_json::from_str(&json).expect("deserialize NewsCard"); + assert_eq!(card, back); + } + + #[test] + fn news_card_thumbnail_none() { + let card = NewsCard { + title: "No Thumb".into(), + source: "bbc.com".into(), + summary: "Summary".into(), + content: "Content".into(), + category: "Tech".into(), + url: "https://bbc.com/article".into(), + thumbnail_url: None, + published_at: "2025-06-01".into(), + }; + let json = serde_json::to_string(&card).expect("serialize"); + let back: NewsCard = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(card, back); + } + + #[test] + fn news_card_thumbnail_some() { + let card = NewsCard { + title: "With Thumb".into(), + source: "cnn.com".into(), + summary: "Summary".into(), + content: "Content".into(), + category: "News".into(), + url: "https://cnn.com/article".into(), + thumbnail_url: Some("https://cnn.com/img.jpg".into()), + published_at: "2025-06-01".into(), + }; + let json = serde_json::to_string(&card).expect("serialize"); + assert!(json.contains("img.jpg")); + let back: NewsCard = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(card.thumbnail_url, back.thumbnail_url); + } +} diff --git a/src/models/organization.rs b/src/models/organization.rs index 790e687..0c6745d 100644 --- a/src/models/organization.rs +++ b/src/models/organization.rs @@ -116,3 +116,122 @@ pub struct OrgBillingRecord { /// Number of tokens consumed during this cycle. pub tokens_used: u64, } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn member_role_label_admin() { + assert_eq!(MemberRole::Admin.label(), "Admin"); + } + + #[test] + fn member_role_label_member() { + assert_eq!(MemberRole::Member.label(), "Member"); + } + + #[test] + fn member_role_label_viewer() { + assert_eq!(MemberRole::Viewer.label(), "Viewer"); + } + + #[test] + fn member_role_all_returns_three_in_order() { + let all = MemberRole::all(); + assert_eq!(all.len(), 3); + assert_eq!(all[0], MemberRole::Admin); + assert_eq!(all[1], MemberRole::Member); + assert_eq!(all[2], MemberRole::Viewer); + } + + #[test] + fn member_role_serde_round_trip() { + for role in MemberRole::all() { + let json = + serde_json::to_string(role).unwrap_or_else(|_| panic!("serialize {:?}", role)); + let back: MemberRole = + serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", role)); + assert_eq!(*role, back); + } + } + + #[test] + fn org_member_serde_round_trip() { + let member = OrgMember { + id: "m1".into(), + name: "Alice".into(), + email: "alice@example.com".into(), + role: MemberRole::Admin, + joined_at: "2025-01-01T00:00:00Z".into(), + }; + let json = serde_json::to_string(&member).expect("serialize OrgMember"); + let back: OrgMember = serde_json::from_str(&json).expect("deserialize OrgMember"); + assert_eq!(member, back); + } + + #[test] + fn pricing_plan_with_max_seats() { + let plan = PricingPlan { + id: "team".into(), + name: "Team".into(), + price_eur: 49, + features: vec!["SSO".into(), "Priority".into()], + highlighted: true, + max_seats: Some(25), + }; + let json = serde_json::to_string(&plan).expect("serialize PricingPlan"); + let back: PricingPlan = serde_json::from_str(&json).expect("deserialize PricingPlan"); + assert_eq!(plan, back); + } + + #[test] + fn pricing_plan_without_max_seats() { + let plan = PricingPlan { + id: "enterprise".into(), + name: "Enterprise".into(), + price_eur: 199, + features: vec!["Unlimited".into()], + highlighted: false, + max_seats: None, + }; + let json = serde_json::to_string(&plan).expect("serialize PricingPlan"); + let back: PricingPlan = serde_json::from_str(&json).expect("deserialize PricingPlan"); + assert_eq!(plan, back); + assert!(json.contains("null") || !json.contains("max_seats")); + } + + #[test] + fn billing_usage_serde_round_trip() { + let usage = BillingUsage { + seats_used: 5, + seats_total: 10, + tokens_used: 1_000_000, + tokens_limit: 5_000_000, + billing_cycle_end: "2025-12-31".into(), + }; + let json = serde_json::to_string(&usage).expect("serialize BillingUsage"); + let back: BillingUsage = serde_json::from_str(&json).expect("deserialize BillingUsage"); + assert_eq!(usage, back); + } + + #[test] + fn org_settings_default() { + let settings = OrgSettings::default(); + assert_eq!(settings.org_id, ""); + assert_eq!(settings.plan_id, ""); + assert!(settings.enabled_features.is_empty()); + assert_eq!(settings.stripe_customer_id, ""); + } + + #[test] + fn org_billing_record_default() { + let record = OrgBillingRecord::default(); + assert_eq!(record.org_id, ""); + assert_eq!(record.cycle_start, ""); + assert_eq!(record.cycle_end, ""); + assert_eq!(record.seats_used, 0); + assert_eq!(record.tokens_used, 0); + } +} diff --git a/src/models/provider.rs b/src/models/provider.rs index a08a637..48ee498 100644 --- a/src/models/provider.rs +++ b/src/models/provider.rs @@ -72,3 +72,84 @@ pub struct ProviderConfig { pub selected_embedding: String, pub api_key_set: bool, } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn llm_provider_label_ollama() { + assert_eq!(LlmProvider::Ollama.label(), "Ollama"); + } + + #[test] + fn llm_provider_label_hugging_face() { + assert_eq!(LlmProvider::HuggingFace.label(), "Hugging Face"); + } + + #[test] + fn llm_provider_label_openai() { + assert_eq!(LlmProvider::OpenAi.label(), "OpenAI"); + } + + #[test] + fn llm_provider_label_anthropic() { + assert_eq!(LlmProvider::Anthropic.label(), "Anthropic"); + } + + #[test] + fn llm_provider_serde_round_trip() { + for variant in [ + LlmProvider::Ollama, + LlmProvider::HuggingFace, + LlmProvider::OpenAi, + LlmProvider::Anthropic, + ] { + let json = serde_json::to_string(&variant) + .unwrap_or_else(|_| panic!("serialize {:?}", variant)); + let back: LlmProvider = + serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", variant)); + assert_eq!(variant, back); + } + } + + #[test] + fn model_entry_serde_round_trip() { + let entry = ModelEntry { + id: "llama3.1:8b".into(), + name: "Llama 3.1 8B".into(), + provider: LlmProvider::Ollama, + context_window: 8192, + }; + let json = serde_json::to_string(&entry).expect("serialize ModelEntry"); + let back: ModelEntry = serde_json::from_str(&json).expect("deserialize ModelEntry"); + assert_eq!(entry, back); + } + + #[test] + fn embedding_entry_serde_round_trip() { + let entry = EmbeddingEntry { + id: "nomic-embed".into(), + name: "Nomic Embed".into(), + provider: LlmProvider::HuggingFace, + dimensions: 768, + }; + let json = serde_json::to_string(&entry).expect("serialize EmbeddingEntry"); + let back: EmbeddingEntry = serde_json::from_str(&json).expect("deserialize EmbeddingEntry"); + assert_eq!(entry, back); + } + + #[test] + fn provider_config_serde_round_trip() { + let cfg = ProviderConfig { + provider: LlmProvider::Anthropic, + selected_model: "claude-3".into(), + selected_embedding: "embed-v1".into(), + api_key_set: true, + }; + let json = serde_json::to_string(&cfg).expect("serialize ProviderConfig"); + let back: ProviderConfig = serde_json::from_str(&json).expect("deserialize ProviderConfig"); + assert_eq!(cfg, back); + } +} diff --git a/src/models/user.rs b/src/models/user.rs index a3367bd..5bbc8f9 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -70,3 +70,81 @@ pub struct UserPreferences { #[serde(default)] pub provider_config: UserProviderConfig, } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn user_data_default() { + let ud = UserData::default(); + assert_eq!(ud.name, ""); + } + + #[test] + fn auth_info_default_not_authenticated() { + let info = AuthInfo::default(); + assert!(!info.authenticated); + assert_eq!(info.sub, ""); + assert_eq!(info.email, ""); + assert_eq!(info.name, ""); + assert_eq!(info.avatar_url, ""); + assert_eq!(info.librechat_url, ""); + } + + #[test] + fn auth_info_serde_round_trip() { + let info = AuthInfo { + authenticated: true, + sub: "sub-123".into(), + email: "test@example.com".into(), + name: "Test User".into(), + avatar_url: "https://example.com/avatar.png".into(), + librechat_url: "https://chat.example.com".into(), + }; + let json = serde_json::to_string(&info).expect("serialize AuthInfo"); + let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo"); + assert_eq!(info, back); + } + + #[test] + fn user_preferences_default() { + let prefs = UserPreferences::default(); + assert_eq!(prefs.sub, ""); + assert_eq!(prefs.org_id, ""); + assert!(prefs.custom_topics.is_empty()); + assert!(prefs.recent_searches.is_empty()); + } + + #[test] + fn user_provider_config_optional_keys_skip_none() { + let cfg = UserProviderConfig { + default_provider: "ollama".into(), + default_model: "llama3.1:8b".into(), + openai_api_key: None, + anthropic_api_key: None, + huggingface_api_key: None, + ollama_url_override: String::new(), + }; + let json = serde_json::to_string(&cfg).expect("serialize UserProviderConfig"); + assert!(!json.contains("openai_api_key")); + assert!(!json.contains("anthropic_api_key")); + assert!(!json.contains("huggingface_api_key")); + } + + #[test] + fn user_provider_config_serde_round_trip_with_keys() { + let cfg = UserProviderConfig { + default_provider: "openai".into(), + default_model: "gpt-4o".into(), + openai_api_key: Some("sk-test".into()), + anthropic_api_key: Some("ak-test".into()), + huggingface_api_key: None, + ollama_url_override: "http://custom:11434".into(), + }; + let json = serde_json::to_string(&cfg).expect("serialize"); + let back: UserProviderConfig = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(cfg, back); + } +}