test: added more tests (#16)
Some checks failed
Some checks failed
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
@@ -120,13 +120,143 @@ jobs:
|
|||||||
run: sccache --show-stats
|
run: sccache --show-stats
|
||||||
if: always()
|
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)
|
# Stage 3: Deploy (only after tests pass, only on main)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy
|
name: Deploy
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
needs: [test]
|
needs: [test, e2e]
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
container:
|
container:
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,3 +22,8 @@ keycloak/*
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
searxng/
|
searxng/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
e2e/.auth/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|||||||
65
Cargo.lock
generated
65
Cargo.lock
generated
@@ -776,6 +776,7 @@ dependencies = [
|
|||||||
"maud",
|
"maud",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
"petname",
|
"petname",
|
||||||
|
"pretty_assertions",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"reqwest 0.13.2",
|
"reqwest 0.13.2",
|
||||||
@@ -783,6 +784,7 @@ dependencies = [
|
|||||||
"secrecy",
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serial_test",
|
||||||
"sha2",
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
@@ -882,6 +884,12 @@ dependencies = [
|
|||||||
"unicode-xid",
|
"unicode-xid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "diff"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -3246,6 +3254,16 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
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]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -3823,6 +3841,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scc"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
|
||||||
|
dependencies = [
|
||||||
|
"sdd",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.28"
|
version = "0.1.28"
|
||||||
@@ -3862,6 +3889,12 @@ dependencies = [
|
|||||||
"untrusted",
|
"untrusted",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sdd"
|
||||||
|
version = "3.0.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrecy"
|
name = "secrecy"
|
||||||
version = "0.10.3"
|
version = "0.10.3"
|
||||||
@@ -4082,6 +4115,32 @@ dependencies = [
|
|||||||
"syn 2.0.116",
|
"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]]
|
[[package]]
|
||||||
name = "servo_arc"
|
name = "servo_arc"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -5683,6 +5742,12 @@ version = "0.8.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yansi"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yazi"
|
name = "yazi"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ server = [
|
|||||||
"dep:bytes",
|
"dep:bytes",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions = "1.4"
|
||||||
|
serial_test = "3.2"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "dashboard"
|
name = "dashboard"
|
||||||
path = "bin/main.rs"
|
path = "bin/main.rs"
|
||||||
|
|||||||
9
bun.lock
9
bun.lock
@@ -8,6 +8,7 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.52.0",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -16,6 +17,8 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|||||||
24
e2e/auth.setup.ts
Normal file
24
e2e/auth.setup.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
72
e2e/auth.spec.ts
Normal file
72
e2e/auth.spec.ts
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
e2e/dashboard.spec.ts
Normal file
75
e2e/dashboard.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
33
e2e/developer.spec.ts
Normal file
33
e2e/developer.spec.ts
Normal file
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
e2e/navigation.spec.ts
Normal file
52
e2e/navigation.spec.ts
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
e2e/organization.spec.ts
Normal file
41
e2e/organization.spec.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
55
e2e/providers.spec.ts
Normal file
55
e2e/providers.spec.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
60
e2e/public.spec.ts
Normal file
60
e2e/public.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.52.0",
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
40
playwright.config.ts
Normal file
40
playwright.config.ts
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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
|
/// post-login redirect URL and the PKCE code verifier needed for the
|
||||||
/// token exchange.
|
/// token exchange.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct PendingOAuthEntry {
|
pub(crate) struct PendingOAuthEntry {
|
||||||
redirect_url: Option<String>,
|
pub(crate) redirect_url: Option<String>,
|
||||||
code_verifier: String,
|
pub(crate) code_verifier: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-memory store for pending OAuth states. Keyed by the random state
|
/// In-memory store for pending OAuth states. Keyed by the random state
|
||||||
@@ -38,7 +38,7 @@ pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
|
|||||||
|
|
||||||
impl PendingOAuthStore {
|
impl PendingOAuthStore {
|
||||||
/// Insert a pending state with an optional redirect URL and PKCE verifier.
|
/// 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
|
// RwLock::write only panics if the lock is poisoned, which
|
||||||
// indicates a prior panic -- propagating is acceptable here.
|
// indicates a prior panic -- propagating is acceptable here.
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
@@ -50,7 +50,7 @@ impl PendingOAuthStore {
|
|||||||
|
|
||||||
/// Remove and return the entry if the state was pending.
|
/// Remove and return the entry if the state was pending.
|
||||||
/// Returns `None` if the state was never stored (CSRF failure).
|
/// Returns `None` if the state was never stored (CSRF failure).
|
||||||
fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
|
pub(crate) fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
self.0
|
self.0
|
||||||
.write()
|
.write()
|
||||||
@@ -60,7 +60,8 @@ impl PendingOAuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a cryptographically random state string for CSRF protection.
|
/// 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();
|
let bytes: [u8; 32] = rand::rng().random();
|
||||||
// Encode as hex to produce a URL-safe string without padding.
|
// Encode as hex to produce a URL-safe string without padding.
|
||||||
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
|
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
|
/// Uses 32 random bytes encoded as base64url (no padding) to produce
|
||||||
/// a 43-character verifier per RFC 7636.
|
/// 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};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
|
||||||
let bytes: [u8; 32] = rand::rng().random();
|
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.
|
/// Derive the S256 code challenge from a code verifier per RFC 7636.
|
||||||
///
|
///
|
||||||
/// `code_challenge = BASE64URL(SHA256(code_verifier))`
|
/// `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 base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
@@ -304,3 +305,117 @@ pub async fn set_login_session(session: Session, data: UserStateInner) -> Result
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| Error::StateError(format!("session insert failed: {e}")))
|
.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -440,7 +440,12 @@ pub async fn chat_complete(
|
|||||||
let session = doc_to_chat_session(&session_doc);
|
let session = doc_to_chat_session(&session_doc);
|
||||||
|
|
||||||
// Resolve provider URL and model
|
// 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
|
// Parse messages from JSON
|
||||||
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
|
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
|
||||||
@@ -480,10 +485,22 @@ pub async fn chat_complete(
|
|||||||
.ok_or_else(|| ServerFnError::new("empty LLM response"))
|
.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")]
|
#[cfg(feature = "server")]
|
||||||
fn resolve_provider_url(
|
pub(crate) fn resolve_provider_url(
|
||||||
state: &crate::infrastructure::ServerState,
|
ollama_url: &str,
|
||||||
|
ollama_model: &str,
|
||||||
provider: &str,
|
provider: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
) -> (String, String) {
|
) -> (String, String) {
|
||||||
@@ -496,12 +513,229 @@ fn resolve_provider_url(
|
|||||||
),
|
),
|
||||||
// Default to Ollama
|
// Default to Ollama
|
||||||
_ => (
|
_ => (
|
||||||
state.services.ollama_url.clone(),
|
ollama_url.to_string(),
|
||||||
if model.is_empty() {
|
if model.is_empty() {
|
||||||
state.services.ollama_model.clone()
|
ollama_model.to_string()
|
||||||
} else {
|
} else {
|
||||||
model.to_string()
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -251,3 +251,160 @@ impl LlmProvidersConfig {
|
|||||||
Ok(Self { providers })
|
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__"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,7 +72,25 @@ mod inner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let html = resp.text().await.ok()?;
|
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 `<article>`, `<main>`, or `[role="main"]`
|
||||||
|
/// 2. Fall back to all `<p>` 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<String> {
|
||||||
|
let document = scraper::Html::parse_document(html);
|
||||||
|
|
||||||
// Strategy 1: Extract from semantic article containers.
|
// Strategy 1: Extract from semantic article containers.
|
||||||
// Most news sites wrap the main content in <article>, <main>,
|
// Most news sites wrap the main content in <article>, <main>,
|
||||||
@@ -134,7 +152,7 @@ mod inner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sum the total character length of all collected text parts.
|
/// 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()
|
parts.iter().map(|s| s.len()).sum()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,3 +343,150 @@ pub async fn chat_followup(
|
|||||||
.map(|choice| choice.message.content.clone())
|
.map(|choice| choice.message.content.clone())
|
||||||
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
|
.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!("<html><body><article><p>{body}</p></article></body></html>");
|
||||||
|
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!("<html><body><main><p>{body}</p></main></body></html>");
|
||||||
|
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 <article>/<main>, so falls back to <p> tags
|
||||||
|
let body = lorem(250);
|
||||||
|
let html = format!("<html><body><div><p>{body}</p></div></body></html>");
|
||||||
|
let result = parse_article_html(&html);
|
||||||
|
assert!(result.is_some(), "expected fallback to <p> tags");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn excludes_nav_footer_aside_content() {
|
||||||
|
// Content only inside excluded containers -- should be excluded
|
||||||
|
let body = lorem(250);
|
||||||
|
let html = format!(
|
||||||
|
"<html><body>\
|
||||||
|
<nav><p>{body}</p></nav>\
|
||||||
|
<footer><p>{body}</p></footer>\
|
||||||
|
<aside><p>{body}</p></aside>\
|
||||||
|
</body></html>"
|
||||||
|
);
|
||||||
|
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 = "<html><body><p>Short.</p></body></html>";
|
||||||
|
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!("<html><body><article><p>{body}</p></article></body></html>");
|
||||||
|
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 = "<html><body><article>\
|
||||||
|
<p>Short frag one</p>\
|
||||||
|
<p>Another tiny bit</p>\
|
||||||
|
</article></body></html>";
|
||||||
|
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!(
|
||||||
|
"<html><body>\
|
||||||
|
<div role=\"main\"><p>{body}</p></div>\
|
||||||
|
</body></html>"
|
||||||
|
);
|
||||||
|
let result = parse_article_html(&html);
|
||||||
|
assert!(result.is_some(), "expected Some for role=main");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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?");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ use dioxus::prelude::*;
|
|||||||
// The #[server] macro generates a client stub for the web build that
|
// The #[server] macro generates a client stub for the web build that
|
||||||
// sends a network request instead of executing this function body.
|
// sends a network request instead of executing this function body.
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
mod inner {
|
pub(crate) mod inner {
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
/// Individual result from the SearXNG search API.
|
/// Individual result from the SearXNG search API.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct SearxngResult {
|
pub(crate) struct SearxngResult {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
@@ -25,7 +25,7 @@ mod inner {
|
|||||||
|
|
||||||
/// Top-level response from the SearXNG search API.
|
/// Top-level response from the SearXNG search API.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct SearxngResponse {
|
pub(crate) struct SearxngResponse {
|
||||||
pub results: Vec<SearxngResult>,
|
pub results: Vec<SearxngResult>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ mod inner {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// The domain host or a fallback "Web" string
|
/// 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)
|
url::Url::parse(url_str)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|u| u.host_str().map(String::from))
|
.and_then(|u| u.host_str().map(String::from))
|
||||||
@@ -64,7 +64,7 @@ mod inner {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Filtered, deduplicated, and ranked results
|
/// Filtered, deduplicated, and ranked results
|
||||||
pub(super) fn rank_and_deduplicate(
|
pub(crate) fn rank_and_deduplicate(
|
||||||
mut results: Vec<SearxngResult>,
|
mut results: Vec<SearxngResult>,
|
||||||
max_results: usize,
|
max_results: usize,
|
||||||
) -> Vec<SearxngResult> {
|
) -> Vec<SearxngResult> {
|
||||||
@@ -285,3 +285,166 @@ pub async fn get_trending_topics() -> Result<Vec<String>, ServerFnError> {
|
|||||||
|
|
||||||
Ok(topics)
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,3 +44,91 @@ pub struct User {
|
|||||||
/// Avatar / profile picture URL.
|
/// Avatar / profile picture URL.
|
||||||
pub avatar_url: String,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,3 +105,163 @@ pub struct ChatMessage {
|
|||||||
pub attachments: Vec<Attachment>,
|
pub attachments: Vec<Attachment>,
|
||||||
pub timestamp: String,
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,3 +45,63 @@ pub struct AnalyticsMetric {
|
|||||||
pub value: String,
|
pub value: String,
|
||||||
pub change_pct: f64,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,3 +23,61 @@ pub struct NewsCard {
|
|||||||
pub thumbnail_url: Option<String>,
|
pub thumbnail_url: Option<String>,
|
||||||
pub published_at: String,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,3 +116,122 @@ pub struct OrgBillingRecord {
|
|||||||
/// Number of tokens consumed during this cycle.
|
/// Number of tokens consumed during this cycle.
|
||||||
pub tokens_used: u64,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,3 +72,84 @@ pub struct ProviderConfig {
|
|||||||
pub selected_embedding: String,
|
pub selected_embedding: String,
|
||||||
pub api_key_set: bool,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,3 +70,81 @@ pub struct UserPreferences {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub provider_config: UserProviderConfig,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user