haiku.rag/tests/ingester/test_config.py
Tres Seaver b78f0ae9ed
feat(ingester): serve control plane under a configurable base path
Add `ingester.api.root_path` so the HTTP control plane (dashboard + API)
can be reverse-proxied behind a sub-path (e.g. /ingester/) on a shared
origin, instead of needing nginx sub_filter URL-rewriting.

- APIConfig.root_path: normalized ('', or single leading slash, no trailing
  slash) via a field_validator; validate_assignment so CLI overrides
  normalize the same way as config-file values.
- Forwarded to FastAPI(root_path=) and uvicorn.Config(root_path=) so
  OpenAPI/docs links are prefix-aware.
- Dashboard route injects a <base href> matching root_path; all dashboard
  fetches are now base-relative, so they resolve under the prefix while
  staying identical at the root.
- `serve --root-path` CLI flag.
- Docs: "Behind a reverse proxy" section with an nginx example.

Closes #431

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:02:43 -04:00

160 lines
4.4 KiB
Python

from pathlib import Path
import pytest
import yaml
from pydantic import ValidationError
from haiku.rag.config import (
APIConfig,
AppConfig,
FSSourceConfig,
HTTPSourceConfig,
IngesterConfig,
RetryPolicyConfig,
S3SourceConfig,
)
def test_default_ingester_config_has_sane_values():
cfg = IngesterConfig()
assert cfg.sources == []
assert cfg.workers.worker_count == 4
assert cfg.workers.retry.max_attempts == 5
assert cfg.api.enabled is True
assert cfg.api.port == 8765
assert cfg.api.root_path == ""
@pytest.mark.parametrize(
"raw, expected",
[
("", ""),
("/", ""),
("ingester", "/ingester"),
("/ingester", "/ingester"),
("/ingester/", "/ingester"),
(" /ingester/ ", "/ingester"),
],
)
def test_api_root_path_normalized(raw, expected):
cfg = APIConfig(root_path=raw)
assert cfg.root_path == expected
def test_api_root_path_normalized_on_assignment():
"""validate_assignment ensures CLI overrides (--root-path) get the same
normalization as values parsed from the config file."""
cfg = APIConfig()
cfg.root_path = "ingester/"
assert cfg.root_path == "/ingester"
def test_discriminator_picks_fs_source():
cfg = IngesterConfig.model_validate(
{"sources": [{"type": "fs", "root": "/tmp/docs"}]}
)
assert isinstance(cfg.sources[0], FSSourceConfig)
assert cfg.sources[0].root == Path("/tmp/docs")
assert cfg.sources[0].delete_orphans is True
def test_discriminator_picks_http_source():
cfg = IngesterConfig.model_validate(
{"sources": [{"type": "http", "id": "arxiv", "urls": ["https://x"]}]}
)
assert isinstance(cfg.sources[0], HTTPSourceConfig)
assert cfg.sources[0].id == "arxiv"
def test_discriminator_picks_s3_source():
cfg = IngesterConfig.model_validate(
{"sources": [{"type": "s3", "uri": "s3://bucket/prefix/"}]}
)
assert isinstance(cfg.sources[0], S3SourceConfig)
assert cfg.sources[0].uri == "s3://bucket/prefix/"
def test_discriminator_picks_webdav_source():
cfg = IngesterConfig.model_validate(
{
"sources": [
{
"type": "webdav",
"id": "nc",
"base_url": "https://nc.example.com/dav/",
"username": "alice",
"password": "hunter2",
}
]
}
)
from haiku.rag.config import WebDAVSourceConfig
assert isinstance(cfg.sources[0], WebDAVSourceConfig)
assert cfg.sources[0].base_url == "https://nc.example.com/dav/"
assert cfg.sources[0].username == "alice"
def test_discriminator_rejects_unknown_type():
with pytest.raises(ValidationError):
IngesterConfig.model_validate({"sources": [{"type": "ftp", "uri": "x"}]})
def test_per_source_retry_overrides_default():
cfg = IngesterConfig.model_validate(
{
"sources": [
{
"type": "fs",
"root": "/tmp",
"retry": {"max_attempts": 10},
}
]
}
)
src = cfg.sources[0]
assert src.retry is not None
assert src.retry.max_attempts == 10
# other retry fields fall back to the RetryPolicyConfig defaults
assert src.retry.base_delay_s == RetryPolicyConfig().base_delay_s
def test_yaml_round_trip():
yaml_text = """
ingester:
sources:
- type: fs
root: /data/docs
ignore_patterns: ["**/.git/**"]
delete_orphans: true
- type: s3
uri: s3://my-bucket/incoming/
poll_interval_s: 300
storage_options:
endpoint: http://seaweed:8333
- type: http
id: arxiv
urls: [https://arxiv.org/pdf/2301.12345.pdf]
headers:
Authorization: Bearer abc
poll_interval_s: 86400
workers:
worker_count: 8
api:
enabled: false
"""
data = yaml.safe_load(yaml_text)
app = AppConfig.model_validate(data)
assert len(app.ingester.sources) == 3
fs, s3, http = app.ingester.sources
assert isinstance(fs, FSSourceConfig)
assert isinstance(s3, S3SourceConfig)
assert isinstance(http, HTTPSourceConfig)
assert fs.ignore_patterns == ["**/.git/**"]
assert s3.storage_options["endpoint"] == "http://seaweed:8333"
assert http.headers["Authorization"] == "Bearer abc"
assert app.ingester.workers.worker_count == 8
assert app.ingester.api.enabled is False