diff --git a/.github/workflows/e2e-bronze-to-api.yml b/.github/workflows/e2e-bronze-to-api.yml index 25ef779eb..0f8c3076b 100644 --- a/.github/workflows/e2e-bronze-to-api.yml +++ b/.github/workflows/e2e-bronze-to-api.yml @@ -24,7 +24,7 @@ name: E2E — Bronze to API # The metric-coverage-gate and api-endpoint-coverage-gate jobs then each # analyse their own lane's artifact as a pure-Python check (no Docker, no app # boot). See -# src/ingestion/tests/e2e/lib/{metric_coverage,collect_metrics,api_coverage}.py. +# src/ingestion/tests/e2e/lib/{metric_coverage,collect_metric_definitions,api_coverage}.py. # # Specs: docs/domain/bronze-to-api-e2e/specs/ # Code: src/ingestion/tests/e2e/ @@ -188,7 +188,7 @@ jobs: # `./e2e.sh test meta/`. # # This session ALSO emits BOTH .artifacts/ files while analytics is up: - # • catalog_metrics.json (lib/collect_metrics.py) + # • metric_definitions.json (lib/collect_metric_definitions.py) # • observed_endpoints.json (conftest.pytest_sessionfinish — httpx hook # ledger) # but only the lane-relevant one is consumed downstream: this api lane @@ -294,7 +294,7 @@ jobs: # `./e2e.sh test meta/`. # # This session ALSO emits BOTH .artifacts/ files while analytics is up: - # • catalog_metrics.json (lib/collect_metrics.py) + # • metric_definitions.json (lib/collect_metric_definitions.py) # • observed_endpoints.json (conftest.pytest_sessionfinish — httpx hook # ledger) # but only the lane-relevant one is consumed downstream: this metrics @@ -371,10 +371,10 @@ jobs: run: | pip install --quiet pyyaml python3 src/ingestion/tests/e2e/lib/metric_coverage.py \ - --universe-file coverage-inputs/catalog_metrics.json | tee metric_coverage.md + --universe-file coverage-inputs/metric_definitions.json | tee metric_coverage.md rc=${PIPESTATUS[0]} if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then cat metric_coverage.md >> "$GITHUB_STEP_SUMMARY"; fi - if [ "$rc" -ne 0 ]; then echo "::error::metric coverage gate failed — a metric_key is value-tested by no test and not in SKIP_TABLES/SKIP_LIST (or a skip is stale). See the report."; fi + if [ "$rc" -ne 0 ]; then echo "::error::metric coverage gate failed — builtin metric coverage is incomplete or stale. See the report."; fi exit "$rc" api-endpoint-coverage-gate: diff --git a/src/ingestion/tests/e2e/README.md b/src/ingestion/tests/e2e/README.md index d50c8c62a..c49be9f57 100644 --- a/src/ingestion/tests/e2e/README.md +++ b/src/ingestion/tests/e2e/README.md @@ -19,8 +19,8 @@ Test framework that exercises the full data path: ``` -metrics/.test.yaml (bronze records) → bronze tables → dbt staging/silver → -ClickHouse migration gold-views → analytics HTTP (POST /v1/metrics/queries) → expect rules +metrics/.test.yaml (bronze records) → bronze tables → dbt staging/silver/gold → +analytics HTTP (POST /v1/metric-results) → expect rules ``` Airbyte / Kestra / Argo are NOT exercised — bronze is seeded by direct INSERT of the @@ -82,9 +82,9 @@ e2e/ │ ├── migration_applier.py # applies src/ingestion/scripts/migrations/*.sql │ ├── analytics.py # builds + spawns the analytics binary │ ├── worker.py # WorkerContext (resolves pytest-xdist worker id) -│ ├── metric_coverage.py # metric-coverage gate: SKIP_TABLES + SKIP_LIST (--universe-file) +│ ├── metric_coverage.py # builtin metric coverage gate │ ├── api_coverage.py # endpoint-coverage report + httpx recording hook -│ ├── collect_metrics.py # script: snapshot the metric catalog → .artifacts/ +│ ├── collect_metric_definitions.py # snapshot enabled builtin definitions │ └── config.py # session config (ports, random creds) ├── seed/ │ └── metrics.yaml # optional test-specific metric overrides (default: empty) @@ -94,24 +94,18 @@ e2e/ ## Metric coverage gate -A job (`metric-coverage-gate`) in the **E2E — Bronze to API** workflow, *not* a pytest test. The `e2e-metrics` lane runs the metrics/ suite and, while analytics is up, snapshots the metric catalog (`POST /v1/catalog/get_metrics`) to `.artifacts/catalog_metrics.json` (uploaded as `coverage-inputs-metrics`); the gate job then checks every product `metric_key` the catalog exposes is value-asserted by a test or covered by a `SKIP_TABLES`/`SKIP_LIST` entry — pure Python, no Docker, no second app boot. +A job (`metric-coverage-gate`) in the **E2E — Bronze to API** workflow checks enabled builtin product definitions. The metrics lane snapshots `metric_key`, computation, dimensions, and cohort metadata to `.artifacts/metric_definitions.json`; the gate then verifies every builtin has period, peer, and timeseries assertions, plus breakdown assertions for declared dimensions and histogram assertions for median metrics. Locally, after a run: ```bash -./e2e.sh test metrics/ # runs the metrics suite (emits both .artifacts files; only catalog_metrics.json feeds this gate) +./e2e.sh test metrics/ # runs the metrics suite and writes metric_definitions.json ./e2e.sh gates metrics # metric gate only, against .artifacts/ (in the runner image; no DB) ``` `./e2e.sh gates` with no argument runs both gates (handy after running both suites locally; see [API endpoint coverage gate](#api-endpoint-coverage-gate) below for the api/-only equivalent). `gates api` / `gates metrics` run one gate against one artifact each — that per-lane shape is what mirrors the two independent CI jobs, each of which only ever needs its own lane's artifact. -The verdict per **metric_key** (each individual number) is **binary**: - -- **value-tested** — a `metrics/*.test.yaml` asserts it (`find: {metric_key: …}` paired with `equal`/`assert`) → **PASS** -- **skip-listed** (in the inline `SKIP_LIST` in [`lib/metric_coverage.py`](lib/metric_coverage.py)) → **PASS** (baseline) -- **neither** → **FAIL** — a number nobody validates must get an assertion or a `SKIP_LIST` entry. - -Catalog keys are dotted (`collab_bullet_rows.m365_emails_sent`); a test asserts the bare response key (`m365_emails_sent`). The column suffix is unique across the catalog, so the gate maps bare→dotted by suffix (a future collision raises). `SKIP_LIST` is the accepted baseline and single source of truth (no side-car file — just `(metric_key, reason)`). Kept honest: a **stale** entry (key no longer in the catalog), a **redundant** one (now value-tested), or a test asserting a **non-catalog** key (typo / unseeded → matches 0 rows) all fail. PASS iff no FAILs. +Missing views, unknown asserted keys, unknown requested keys, and stale fixtures fail. There are no legacy suffix mappings or skip lists. ```bash # ad hoc against a running analytics (instead of the collected artifact): @@ -133,7 +127,7 @@ Locally, after a run: Per-status-code coverage is **reported, not enforced**: the report renders an endpoints × registered-status-codes table (`✓` observed · `✗` declared but not yet observed · `·` excluded · blank = not declared) and an overall coverage percentage. A code is *coverable* — and so counts toward the percentage — only if a black-box rig can produce it: `coverable(op) = declared(op) − {codes ≥ 500} − UNIVERSAL_BOILERPLATE{401,429} − BLOCKED[op]`. `BLOCKED` absorbs the committed spec's `.standard_errors` over-declaration (#1669) plus pinned rig/product limits, and a `·` code that becomes observed (or a `BLOCKED` op dropped from the spec) is surfaced as a non-blocking advisory so the list stays honest. -The [`api/`](api/) contract suite covers all 21 spec operations — one module per path group (`test_metrics.py`, `test_metric_thresholds.py`, `test_admin_thresholds.py`, `test_catalog.py`, `test_columns.py`, `test_persons.py`, `test_metric_results.py`), one test per (path, method, status-code) case, from self-cleaning fixtures (`api/conftest.py`) — so `SKIP_LIST` is empty and adding a spec operation without a test fails the gate as MISSING. Spec/product gaps are pinned by **strict xfails** rather than fixed here: #1663 (legacy threshold reads 500 once a row exists), #1664 (duplicate admin create answers 500 instead of the declared 409), and #1670 (off-schema legacy body answers a non-canonical 422, not the intended 400). `POST /v1/metric-results` — the unified-metric compute endpoint added by the `feat/unified-metrics` merge (#1656) — is covered on its deterministic error paths (400 empty/bad-period/unknown-key, 415 wrong content-type); its 200 happy-path needs seeded unified-metric observation data and shows as a reported `✗` gap until that fixture lands. +The [`api/`](api/) contract suite covers all 21 spec operations — one module per path group (`test_metrics.py`, `test_metric_thresholds.py`, `test_admin_thresholds.py`, `test_catalog.py`, `test_columns.py`, `test_persons.py`, `test_metric_results.py`), one test per (path, method, status-code) case, from self-cleaning fixtures (`api/conftest.py`). The declarative metrics suite covers successful `/v1/metric-results` computation for every builtin metric. ## Ports (loopback only) @@ -153,16 +147,17 @@ These ports avoid conflict with a local gitops dev cluster (which forwards 8123 ## `cases` / `expect` (declarative YAML rig) -Tests are `metrics/**/*.test.yaml`; each `case` POSTs a batch to `/v1/metrics/queries` and checks an `expect` list of rules. A rule selects with `in` (batch result by `id`) + an exact-equality `find` (`{field: value}`), then asserts via `equal` (subset of fields, exact / `null`) or `assert` (a CEL boolean). Anything richer than equality (inequalities, counts, predicates) goes in a CEL `assert` — the rig deliberately has no second selector language. See the [yaml-rig FEATURE](../../../../docs/domain/bronze-to-api-e2e/specs/feature-yaml-rig/FEATURE.md) and the `/metric` skill. +Tests are `metrics/**/*.test.yaml`; each case posts to `/v1/metric-results`. Rules select a full builtin `metric` key and `view`, then optionally use `find` for a row and `equal` for exact or null values. Use CEL `assert` for nested timeseries points, dimensions, histogram bins, counts, and predicates. Variables available in an `assert` (CEL) expression — assembled in `lib/expect_engine.py::evaluate_case` (the `bindings` dict), converted to CEL in `_eval_cel`: | Binding | Value | Present when | |---------|-------|--------------| | `it` | the single row matched by `find` | only with `find` (else `null`) | -| `items` | the selected result's `items` array | a result is selected (`in` or sole query) | -| `result` | the selected batch result `{id, status, metric_id, items, page_info}` | a result is selected | -| `results` | the full `results[]` of the batch | always | +| `items` | values or series from the selected view | a metric view is selected | +| `view` | the selected view object | a metric view is selected | +| `metric` | the selected metric result | a metric is selected | +| `metrics` | all metric results | always | | `status` | the batch HTTP status code (int) | always | CEL is strictly typed and will not compare an `int` to a `double`. Bindings are passed through unchanged, so when a metric value may be integral (e.g. `40`) and you compare against a fractional literal, cast it: `double(it.value) > 39.5`. `status` and `size(...)` are integers — compare them with integer literals. Use `equal` for exact / `null` comparisons (it uses Python `==`). @@ -171,25 +166,22 @@ CEL is strictly typed and will not compare an `int` to a `double`. Bindings are `assert` expressions are written in **CEL — the [Common Expression Language](https://github.com/google/cel-spec)** (the same expression language used by Kubernetes admission policies and Envoy). It is a small, side-effect-free language for boolean/value expressions over structured data: no statements, no loops, no I/O — an expression is evaluated against the bindings above and must return a boolean. The rig evaluates it with the [`cel-python`](https://pypi.org/project/cel-python/) library (`celpy`) in `lib/expect_engine.py::_eval_cel`. -Operators: `== != < <= > >=`, `&& || !`, `+ - * / %`, `in`, ternary `cond ? a : b`. Field/index access: `it.value`, `result.status`, `items[0]`. Useful built-ins & macros: `size(x)`, `has(x.field)`, `x.exists(e, )`, `x.all(e, )`, `x.filter(e, )`, `x.map(e, )`, string `.startsWith()/.endsWith()/.contains()/.matches(re)`. +Operators: `== != < <= > >=`, `&& || !`, `+ - * / %`, `in`, ternary `cond ? a : b`. Field/index access: `it.value`, `view.values`, `items[0]`. Useful built-ins & macros: `size(x)`, `has(x.field)`, `x.exists(e, )`, `x.all(e, )`, `x.filter(e, )`, `x.map(e, )`, string `.startsWith()/.endsWith()/.contains()/.matches(re)`. Examples: ```yaml -- assert: "status == 200" # batch HTTP code -- in: collaboration - assert: "result.status == 'ok'" # this query's own status -- in: collaboration - assert: "size(items) == 20" # row count -- in: collaboration - find: { metric_key: m365_emails_sent } - assert: "double(it.value) > 39.5 && double(it.value) < 40.5" # cast to double for fractional compare -- in: collaboration - find: { metric_key: slack_dm_ratio } - assert: "it.value == null" # explicit null -- assert: "results.exists(r, r.status == 'error')" # any query in the batch failed? -- in: collaboration - assert: "items.all(r, r.range_min <= r.value)" # invariant across all rows +- assert: "status == 200" +- metric: collab.emails_sent + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } +- metric: collab.messages_sent + view: timeseries + assert: "items.exists(s, s.points.exists(p, double(p.value) > 0.0))" +- metric: git.lines_added + view: breakdown + assert: "items.exists(v, v.dimensions.exists(d, d.key == 'category' && d.value == 'code'))" ``` Prefer `equal` for exact / `null` checks (it uses Python `==`, so `40 == 40.0` and `value: null` work directly); reach for `assert` when you need inequalities, counts, or cross-row predicates. diff --git a/src/ingestion/tests/e2e/api/test_metric_results.py b/src/ingestion/tests/e2e/api/test_metric_results.py index 704e54faf..4244768ec 100644 --- a/src/ingestion/tests/e2e/api/test_metric_results.py +++ b/src/ingestion/tests/e2e/api/test_metric_results.py @@ -3,9 +3,7 @@ POST /v1/metric-results 200 compute · 400 (empty/bad-period/unknown-key) · 415 wrong-ct Added when the `feat/unified-metrics` merge (#1656) introduced this operation to -the committed spec. It computes results for CATALOG metrics (`metric_key` + -computation spec over the observation gold-views) — a different system from the -legacy `query_ref` metrics the `/v1/metrics` CRUD and the scratch fixtures use. +the committed spec. It computes builtin metrics over unified observation models. The endpoint validates its request body in `domain::metric_results::validate_request` BEFORE touching ClickHouse, so the whole 400 family is deterministic in the rig: @@ -13,12 +11,8 @@ (which is a 400 via `unavailable`, NOT a 404) all reject up front. Wrong Content-Type is a 415 at the `axum::Json` extractor. -The 200 happy-path is deferred: a deterministic compute needs a seeded catalog -metric whose observation gold-view is built in the rig, which this branch does -not yet stand up — the coverage report marks metric-results' 200 as a `✗` gap -until that fixture lands. The endpoint-coverage gate blocks on an *unexercised* -operation, not on that per-code gap, so these error-path cases are enough to -keep the new endpoint covered. +The declarative metric suite exercises deterministic 200 responses for every +builtin metric; this module retains the endpoint contract error cases. """ from __future__ import annotations diff --git a/src/ingestion/tests/e2e/conftest.py b/src/ingestion/tests/e2e/conftest.py index caa5ed1ad..b20d6b620 100644 --- a/src/ingestion/tests/e2e/conftest.py +++ b/src/ingestion/tests/e2e/conftest.py @@ -206,37 +206,18 @@ def dbt_runner(ch_migrations_applied: SessionConfig): runner.cleanup() -def _collect_metrics(proc: AnalyticsProcess) -> None: - """Run `lib/collect_metrics.py` (a script — NOT a test) against the - live API, primary worker only. Snapshots the metric catalog into `.artifacts/` - so the metric-coverage gate analyses a file with no second app boot. - Best-effort: a failure just means the gate finds no artifact and fails loudly — - never abort the session for it. Must run while the API is up (called from - analytics teardown, before proc.stop()).""" +def _collect_metrics(cfg: SessionConfig) -> None: if not _IS_PRIMARY: return - import subprocess - import sys + from lib.collect_metric_definitions import collect - script = Path(__file__).parent / "lib" / "collect_metrics.py" out_dir = Path(__file__).parent / ".artifacts" - result = subprocess.run( - [ - sys.executable, - str(script), - "--url", - proc.base_url, - "--out-dir", - str(out_dir), - "--tenant", - str(TEST_TENANT_ID), - ], - check=False, - ) - if result.returncode != 0: + try: + collect(cfg, out_dir) + except Exception as error: LOG.warning( - "coverage-artifact collection failed (rc=%d); gate jobs may lack inputs", - result.returncode, + "coverage-artifact collection failed: %s; gate jobs may lack inputs", + error, ) @@ -258,7 +239,12 @@ def identity_stub(): @pytest.fixture(scope="session") -def analytics(ch_migrations_applied: SessionConfig, identity_stub: IdentityStub): +def analytics( + ch_migrations_applied: SessionConfig, + dbt_runner: DbtRunner, + worker_ctx: WorkerContext, + identity_stub: IdentityStub, +): """Spawn the analytics binary baked into the runner image. Its SeaORM migrations run on startup; we then upsert test-specific metrics from seed/metrics.yaml. @@ -270,6 +256,7 @@ def analytics(ch_migrations_applied: SessionConfig, identity_stub: IdentityStub) bronze→API tests cannot run, so the only honest result is red. """ cfg = ch_migrations_applied + dbt_runner.run("tag:gold", worker_ctx=worker_ctx) from lib.analytics import ApiSpawnError # local import to keep top clean try: binary = locate_binary(cfg) @@ -280,11 +267,8 @@ def analytics(ch_migrations_applied: SessionConfig, identity_stub: IdentityStub) proc.start() seed_test_metrics(cfg) yield proc - # Snapshot the metric catalog while the API is still up (a script, run via - # subprocess — see _collect_metrics). Always - # stop the process afterward, even if collection raised. try: - _collect_metrics(proc) + _collect_metrics(cfg) finally: proc.stop() diff --git a/src/ingestion/tests/e2e/e2e.sh b/src/ingestion/tests/e2e/e2e.sh index ceda692ad..1643d95fb 100755 --- a/src/ingestion/tests/e2e/e2e.sh +++ b/src/ingestion/tests/e2e/e2e.sh @@ -98,8 +98,8 @@ case "$cmd" in ;; esac if [ "$which" = all ] || [ "$which" = metrics ]; then - if [ ! -f .artifacts/catalog_metrics.json ]; then - echo "no .artifacts/catalog_metrics.json — run './e2e.sh test metrics/' first (it collects the metric catalog)" >&2 + if [ ! -f .artifacts/metric_definitions.json ]; then + echo "no .artifacts/metric_definitions.json — run './e2e.sh test metrics/' first" >&2 exit 2 fi fi @@ -114,7 +114,7 @@ case "$cmd" in rc=0 if [ "$which" = all ] || [ "$which" = metrics ]; then echo "── metric coverage (gate) ──" - "${run[@]}" python3 lib/metric_coverage.py --universe-file .artifacts/catalog_metrics.json || rc=1 + "${run[@]}" python3 lib/metric_coverage.py --universe-file .artifacts/metric_definitions.json || rc=1 fi if [ "$which" = all ] || [ "$which" = api ]; then echo "── api endpoint coverage (gate) ──" diff --git a/src/ingestion/tests/e2e/lib/analytics.py b/src/ingestion/tests/e2e/lib/analytics.py index 0f81d40be..b22af4d54 100644 --- a/src/ingestion/tests/e2e/lib/analytics.py +++ b/src/ingestion/tests/e2e/lib/analytics.py @@ -257,8 +257,7 @@ def client(self) -> httpx.Client: def call_request(self, request: dict) -> tuple[int, Any]: """Execute a `case.request` ({url, method, body}). Return (status_code, json|text). - Used by the YAML rig; the primary endpoint is the batch - `POST /v1/metrics/queries`. The body is sent as JSON. + Used by the YAML rig. The body is sent as JSON. """ url = request["url"] method = str(request.get("method", "POST")).upper() diff --git a/src/ingestion/tests/e2e/lib/ch_seeder.py b/src/ingestion/tests/e2e/lib/ch_seeder.py index d1304c9ee..5b035fa41 100644 --- a/src/ingestion/tests/e2e/lib/ch_seeder.py +++ b/src/ingestion/tests/e2e/lib/ch_seeder.py @@ -68,10 +68,11 @@ def truncate_table(self, schema: str, table: str) -> None: """ self._truncate(schema, table) - def seed_bronze(self, bronze: dict[str, list[dict]]) -> None: + def seed_bronze(self, bronze: dict[str, list[dict]], schemas: dict[str, dict]) -> None: """Seed every `.: [records]` entry of a TestYaml.""" for table_fqn, rows in bronze.items(): schema, _, table = table_fqn.partition(".") + self._ensure_table(schema, table, schemas[table_fqn]) self.seed_records(schema, table, rows) self.ledger.record(schema, table) @@ -132,6 +133,19 @@ def _fetch_column_types(self, schema: str, table: str) -> dict[str, str]: ) return {name: ctype for name, ctype in rows} + def _ensure_table(self, database: str, table: str, schema: dict[str, Any]) -> None: + if self._fetch_column_types(database, table): + return + columns = ", ".join( + f"`{name}` {_clickhouse_type(definition)}" + for name, definition in schema.get("properties", {}).items() + ) + ch.execute(self.cfg, f"CREATE DATABASE IF NOT EXISTS `{database}`") + ch.execute( + self.cfg, + f"CREATE TABLE `{database}`.`{table}` ({columns}) ENGINE = MergeTree ORDER BY tuple()", + ) + @staticmethod def _coerce(value: Any, ch_type: str, col: str) -> Any: """Coerce a YAML-parsed value to what clickhouse-connect expects for `ch_type`.""" @@ -166,3 +180,19 @@ def _strip_nullable(ch_type: str) -> str: if ch_type.startswith("Nullable(") and ch_type.endswith(")"): return ch_type[len("Nullable(") : -1] return ch_type + + +def _clickhouse_type(definition: dict[str, Any]) -> str: + declared = definition.get("type", "string") + types = declared if isinstance(declared, list) else [declared] + base = next((item for item in types if item != "null"), "string") + if base == "array": + return f"Array({_clickhouse_type(definition.get('items', {})).removeprefix('Nullable(').removesuffix(')')})" + mapped = { + "boolean": "Bool", + "integer": "Int64", + "number": "Float64", + "string": "String", + "object": "String", + }.get(base, "String") + return f"Nullable({mapped})" diff --git a/src/ingestion/tests/e2e/lib/collect_metric_definitions.py b/src/ingestion/tests/e2e/lib/collect_metric_definitions.py new file mode 100644 index 000000000..257621708 --- /dev/null +++ b/src/ingestion/tests/e2e/lib/collect_metric_definitions.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from lib import mariadb +from lib.config import SessionConfig + + +def collect(cfg: SessionConfig, out_dir: str | Path) -> Path: + rows = mariadb.query( + cfg, + """ + SELECT + d.metric_key, + d.label, + d.computation_type, + d.peer_cohort_key, + GROUP_CONCAT(sd.dimension_key ORDER BY dd.display_order SEPARATOR ',') + FROM metric_definitions d + LEFT JOIN metric_definition_dimensions dd ON dd.metric_definition_id = d.id + LEFT JOIN metric_source_dimensions sd ON sd.id = dd.source_dimension_id + WHERE d.tenant_id IS NULL + AND d.origin = 'builtin' + AND d.is_enabled = TRUE + GROUP BY d.id, d.metric_key, d.label, d.computation_type, d.peer_cohort_key + ORDER BY d.metric_key + """, + ) + if not rows: + raise RuntimeError("metric definition collection returned no builtin metrics") + metrics = [ + { + "metric_key": metric_key, + "label": label, + "computation": computation, + "peer_cohort_key": peer_cohort_key, + "dimensions": dimensions.split(",") if dimensions else [], + } + for metric_key, label, computation, peer_cohort_key, dimensions in rows + ] + output_dir = Path(out_dir) + output_dir.mkdir(parents=True, exist_ok=True) + output = output_dir / "metric_definitions.json" + output.write_text(json.dumps({"metrics": metrics}, indent=2) + "\n", encoding="utf-8") + return output diff --git a/src/ingestion/tests/e2e/lib/collect_metrics.py b/src/ingestion/tests/e2e/lib/collect_metrics.py deleted file mode 100755 index 3f0310903..000000000 --- a/src/ingestion/tests/e2e/lib/collect_metrics.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -"""Snapshot the metric catalog from a running analytics. - -NOT a pytest test — a plain script, run (via subprocess) by conftest's -`analytics` fixture while the suite's analytics is up, so the -metric-coverage gate can read a file with no second app boot. Writes -``catalog_metrics.json`` (← POST /v1/catalog/get_metrics) into ``--out-dir``. - -Standalone: - python3 lib/collect_metrics.py \ - --url http://127.0.0.1:8081 --out-dir .artifacts \ - --tenant 00000000-0000-0000-0000-000000000001 -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - - -def collect(base_url: str, out_dir: str | Path, tenant_id: str | None) -> None: - """Fetch the metric catalog and write it to ``out_dir``. - - Fail-fast (raises) if the response is empty — a missing/empty artifact would - otherwise surface only as a confusing downstream gate failure. - """ - import httpx # local import: keeps this importable without httpx - - headers: dict[str, str] = {} - if tenant_id: - # the catalog read is tenant-gated. - headers["X-Insight-Tenant-Id"] = tenant_id - with httpx.Client(base_url=base_url, timeout=30.0, headers=headers) as c: - catalog = c.post("/v1/catalog/get_metrics", json={}) - catalog.raise_for_status() - - catalog_doc = catalog.json() - if not (isinstance(catalog_doc, dict) and catalog_doc.get("metrics")): - raise SystemExit(f"collect: POST {base_url}/v1/catalog/get_metrics returned no metrics") - - out = Path(out_dir) - out.mkdir(parents=True, exist_ok=True) - (out / "catalog_metrics.json").write_text(json.dumps(catalog_doc, indent=2) + "\n", encoding="utf-8") - print(f"collected catalog_metrics.json ({len(catalog_doc['metrics'])} metrics) -> {out}") - - -def main(argv: list[str] | None = None) -> int: - p = argparse.ArgumentParser( - description="Snapshot the analytics metric catalog for the coverage gate." - ) - p.add_argument("--url", required=True, help="analytics base URL") - p.add_argument("--out-dir", required=True, help="directory to write the artifact into") - p.add_argument("--tenant", help="X-Insight-Tenant-Id header (the catalog read is tenant-gated)") - args = p.parse_args(argv) - collect(args.url, args.out_dir, args.tenant) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/ingestion/tests/e2e/lib/dbt_runner.py b/src/ingestion/tests/e2e/lib/dbt_runner.py index 7f3d0ca06..77c99cfdb 100644 --- a/src/ingestion/tests/e2e/lib/dbt_runner.py +++ b/src/ingestion/tests/e2e/lib/dbt_runner.py @@ -30,6 +30,7 @@ import yaml from dbt.cli.main import dbtRunner +from lib import clickhouse as ch from lib.config import SessionConfig from lib.worker import WorkerContext @@ -75,7 +76,11 @@ def build( """ if self._runner is None: raise DbtError("dbt_runner.setup() must be called before build()") - worker_n = worker_ctx.worker_id.removeprefix("gw") if worker_ctx.worker_id != "master" else "0" + worker_n = ( + worker_ctx.worker_id.removeprefix("gw") + if worker_ctx.worker_id != "master" + else "0" + ) LOG.info("dbt build --select %s (worker=%s)", selector, worker_ctx.worker_id) res = self._runner.invoke( [ @@ -98,6 +103,48 @@ def build( f"exception: {res.exception!r}" ) + def run( + self, + selector: str, + *, + worker_ctx: WorkerContext, + full_refresh: bool = False, + ) -> None: + if self._runner is None: + raise DbtError("dbt_runner.setup() must be called before run()") + worker_n = ( + worker_ctx.worker_id.removeprefix("gw") + if worker_ctx.worker_id != "master" + else "0" + ) + LOG.info( + "dbt run --select %s%s (worker=%s)", + selector, + " --full-refresh" if full_refresh else "", + worker_ctx.worker_id, + ) + res = self._runner.invoke( + [ + "run", + "--select", + selector, + *(["--full-refresh"] if full_refresh else []), + *self._base_flags(), + "--defer", + "--state", + str(self.target_dir), + "--vars", + json.dumps({"worker_id": worker_n}), + ] + ) + if not res.success: + failed = self._extract_failed_model_summary() + raise DbtError( + f"dbt run failed for selector {selector!r}\n" + f"failed models: {failed}\n" + f"exception: {res.exception!r}" + ) + def derive_selectors(self, tables: set[tuple[str, str]]) -> tuple[list[str], list[str]]: """From the seeded bronze tables, find the dbt models to build. @@ -110,14 +157,67 @@ class models. manifest_path = self.target_dir / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) wanted = {f".{schema}.{table}" for schema, table in tables} - staging: list[str] = [] - silver: set[str] = set() - for node in manifest.get("nodes", {}).values(): + wanted_sources = { + source_id + for source_id, source in manifest.get("sources", {}).items() + if (source.get("schema"), source.get("identifier") or source.get("name")) + in tables + } + existing_tables = set( + ch.query( + self.cfg, + "SELECT database, name FROM system.tables WHERE database LIKE 'bronze_%'", + ) + ) + available_sources = { + source_id + for source_id, source in manifest.get("sources", {}).items() + if (source.get("schema"), source.get("identifier") or source.get("name")) + in existing_tables | tables + } + nodes = manifest.get("nodes", {}) + children: dict[str, set[str]] = {} + for node_id, node in nodes.items(): + for dependency in node.get("depends_on", {}).get("nodes", []): + children.setdefault(dependency, set()).add(node_id) + + direct: set[str] = set() + for node_id, node in nodes.items(): if node.get("resource_type") != "model": continue deps = node.get("depends_on", {}).get("nodes", []) - if not any(d.startswith("source.") and d.endswith(suffix) for d in deps for suffix in wanted): - continue + source_deps = { + dependency + for dependency in deps + if dependency.startswith("source.") + } + matched_sources = { + dependency + for dependency in source_deps + if dependency in wanted_sources + or any(dependency.endswith(suffix) for suffix in wanted) + } + if matched_sources and source_deps <= available_sources: + direct.add(node_id) + + staging_ids = set(direct) + pending = list(direct) + while pending: + parent = pending.pop() + for child in children.get(parent, set()): + node = nodes.get(child, {}) + if node.get("resource_type") != "model": + continue + if node.get("config", {}).get("schema") != "staging": + continue + if child not in staging_ids: + staging_ids.add(child) + pending.append(child) + + staging: list[str] = [] + silver: set[str] = set() + for node_id in staging_ids: + node = nodes[node_id] staging.append(node["name"]) for tag in node.get("tags", []): if tag.startswith("silver:"): diff --git a/src/ingestion/tests/e2e/lib/expect_engine.py b/src/ingestion/tests/e2e/lib/expect_engine.py index 8defeb730..c61e40728 100644 --- a/src/ingestion/tests/e2e/lib/expect_engine.py +++ b/src/ingestion/tests/e2e/lib/expect_engine.py @@ -1,198 +1,181 @@ -"""Evaluate `expect` rules against a batch response. - -Implements `cpt-bronze-to-api-e2e-algo-yaml-eval-expect` -(DoD `cpt-bronze-to-api-e2e-dod-yaml-expect-engine`). - -Each rule: - in: select the batch result by request id (omit when one query) - find: exact field-equality selector → exactly one row of result.items (binds `it`) - then ONE of: - equal: subset equality on the matched row (explicit null supported) - assert: CEL boolean over bindings `it`, `items`, `result`, `results`, `status` - -`find` is intentionally exact-equality only — anything richer (inequalities, -counts, predicates) is expressed in a CEL `assert`, so the rig does not carry a -second selector mini-language (CEL is already the assertion language). - -No-unasserted-stat rule ------------------------- -A bullet row the API returns carries up to six stat fields: -`value, median, range_min, range_max, p25, p75` (there is no `p50` — the 50th -percentile is returned as `median`). Within one `case`, every `find`-matched row -that CARRIES a stat field MUST have that field asserted — either by an `equal` -key or by referencing it in a CEL `assert` on the same row — otherwise this -module raises `ExpectError`. This stops a fixture from silently ignoring a stat -the API returns (e.g. asserting `value`/`median` but forgetting `p25`/`p75`). - -The rule is self-scoping: a stat field ABSENT from the row is never required, so -a row that legitimately returns only `value`/`median`/`range_*` passes without a -`p25`/`p75` assertion. Empty-window cases (a `find` that matches 0 rows binds no -`it`) are naturally exempt. The gate runs AFTER all explicit rules pass, so the -existing first-explicit-failure semantics are preserved. -""" - from __future__ import annotations import math import re - +from functools import cache from typing import Any import celpy -# Stat fields the bullet/batch endpoint returns per row. `median` is the 50th -# percentile (the API returns no `p50`). Every one of these that a matched row -# carries must be asserted within its case — see the no-unasserted-stat rule. -_STAT_FIELDS = ("value", "median", "range_min", "range_max", "p25", "p75") - - -def _stat_refs_in_cel(expr: str) -> set[str]: - """Stat fields READ off the matched row (`it.` or `it[""]`) in a - CEL `assert`. Scoped to actual `it` access so a stat NAME that merely appears - in a string literal or on another object (e.g. `it.metric_key == "p75"`) does - NOT count as asserting that stat — otherwise a fixture could bypass the - no-unasserted-stat gate by mentioning the name. `_STAT_FIELDS` are literals, - but `re.escape` keeps the pattern hardcoded-safe regardless.""" - refs: set[str] = set() - for f in _STAT_FIELDS: - name = re.escape(f) - if re.search(rf"\bit\.{name}\b", expr) or re.search(rf'\bit\[["\']{name}["\']\]', expr): - refs.add(f) - return refs - - -def _values_equal(got: Any, exp: Any) -> bool: - """Equality for an `equal:` field. - - Two numbers compare with a tolerance: the API rounds floats to 4 decimals, - so a fractional stat (a ratio, an average) must not be rejected by binary - float drift. `abs_tol` (1e-6) sits far below the API's 4dp quantum (1e-4), so - a tolerant pass never masks a real mismatch — two distinct served values - differ by at least 1e-4. Non-numbers (str / None / bool) compare exactly; - bool is excluded from the numeric path so `True`/`1` do not cross-match. - """ - if ( - isinstance(got, (int, float)) - and isinstance(exp, (int, float)) - and not isinstance(got, bool) - and not isinstance(exp, bool) - ): - return math.isclose(got, exp, rel_tol=1e-9, abs_tol=1e-6) - return got == exp - - class ExpectError(AssertionError): - """A failing expect rule. Message names the case, rule and the mismatch.""" - - -# --------------------------------------------------------------------------- -# find — exact field equality -# --------------------------------------------------------------------------- + pass -def _find(items: list[dict], selector: dict) -> list[dict]: - """Rows whose every selected field equals the given value (exact match).""" - return [it for it in items if all(it.get(f) == v for f, v in selector.items())] +_CEL_ENV = celpy.Environment() +_VIEW_ITEMS = { + "period": "values", + "peer": "values", + "timeseries": "series", + "breakdown": "values", + "histogram": "values", +} +_REQUIRED_VIEW_FIELDS = { + "period": {"value"}, + "peer": {"target_value", "p25", "median", "p75", "min", "max", "n"}, + "timeseries": {"points"}, + "breakdown": {"value"}, + "histogram": {"bins"}, +} + + +@cache +def _cel_program(expr: str) -> Any: + ast = _CEL_ENV.compile(expr) + return _CEL_ENV.program(ast) -# --------------------------------------------------------------------------- -# CEL -# --------------------------------------------------------------------------- -_CEL_ENV = celpy.Environment() +def _eval_cel(expr: str, bindings: dict[str, Any]) -> bool: + activation = { + key: celpy.json_to_cel(value) + for key, value in bindings.items() + if re.search(rf"\b{re.escape(key)}\b", expr) + } + return bool(_cel_program(expr).evaluate(activation)) -def _eval_cel(expr: str, bindings: dict) -> bool: - ast = _CEL_ENV.compile(expr) - prog = _CEL_ENV.program(ast) - activation = {k: celpy.json_to_cel(v) for k, v in bindings.items()} - result = prog.evaluate(activation) - return bool(result) - - -# --------------------------------------------------------------------------- -# Rule evaluation -# --------------------------------------------------------------------------- - -def _select_result(rule: dict, results: list[dict], where: str) -> dict | None: - if "in" in rule: - wanted = rule["in"] - for r in results: - if r.get("id") == wanted: - return r - raise ExpectError(f"{where}: no batch result with id '{wanted}' (have {[r.get('id') for r in results]})") - if len(results) == 1: - return results[0] - return None - - -def evaluate_case(case: dict, batch: dict, http_status: int) -> None: - """Run every rule of `case`. Raise ExpectError on the first failure.""" +def _values_equal(got: Any, expected: Any) -> bool: + if ( + isinstance(got, (int, float)) + and isinstance(expected, (int, float)) + and not isinstance(got, bool) + and not isinstance(expected, bool) + ): + return math.isclose(got, expected, rel_tol=1e-9, abs_tol=1e-6) + return got == expected + + +def _matches(value: Any, selector: Any) -> bool: + if isinstance(selector, dict): + if isinstance(value, dict): + return all(key in value and _matches(value[key], expected) for key, expected in selector.items()) + if isinstance(value, list): + return any(_matches(item, selector) for item in value) + return False + return _values_equal(value, selector) + + +def _select_one(rows: list[dict[str, Any]], selector: dict[str, Any], where: str) -> dict[str, Any]: + matches = [row for row in rows if _matches(row, selector)] + if len(matches) != 1: + raise ExpectError(f"{where}: find {selector} matched {len(matches)} rows (expected exactly 1)") + return matches[0] + + +def _select_metric( + rule: dict[str, Any], metrics: list[dict[str, Any]], where: str +) -> dict[str, Any] | None: + metric_key = rule.get("metric") + if metric_key is None: + return None + matches = [metric for metric in metrics if metric.get("metric_key") == metric_key] + if len(matches) != 1: + raise ExpectError( + f"{where}: metric {metric_key!r} matched {len(matches)} metrics (expected exactly 1)" + ) + return matches[0] + + +def _select_view( + rule: dict[str, Any], metric: dict[str, Any] | None, where: str +) -> dict[str, Any] | None: + view_kind = rule.get("view") + if view_kind is None: + return None + if metric is None: + raise ExpectError(f"{where}: `view` requires `metric`") + matches = [view for view in metric.get("views", []) if view.get("view") == view_kind] + if len(matches) != 1: + raise ExpectError( + f"{where}: view {view_kind!r} matched {len(matches)} views (expected exactly 1)" + ) + return matches[0] + + +def _asserted_fields(rule: dict[str, Any]) -> set[str]: + fields = set((rule.get("equal") or {}).keys()) + fields.update((rule.get("contains") or {}).keys()) + fields.update(rule.get("nonempty") or []) + expression = rule.get("assert") + if expression: + fields.update(re.findall(r"\bit\.([a-zA-Z_][a-zA-Z0-9_]*)\b", expression)) + fields.update(re.findall(r"\bit\[['\"]([a-zA-Z_][a-zA-Z0-9_]*)['\"]\]", expression)) + return fields + + +def evaluate_case(case: dict[str, Any], response: Any, http_status: int) -> None: name = case.get("name", "") - results = batch.get("results", []) if isinstance(batch, dict) else [] - - # Per-case ledger of every `find`-matched row and the stat fields asserted on - # it, keyed by row identity. Drives the no-unasserted-stat gate after the loop. - checked: dict[int, dict] = {} - - for i, rule in enumerate(case.get("expect", [])): - where = f"case '{name}' rule #{i}" - result = _select_result(rule, results, where) - items = result.get("items", []) if result else [] - - it = None - if "find" in rule: - matches = _find(items, rule["find"]) - if len(matches) != 1: - raise ExpectError( - f"{where}: find {rule['find']} matched {len(matches)} rows (expected exactly 1)" - ) - it = matches[0] - - if it is not None: - entry = checked.setdefault( - id(it), - {"row": it, "find": rule["find"], "where": where, "asserted": set()}, - ) + metrics = response.get("metrics", []) if isinstance(response, dict) else [] + checked: dict[tuple[str, int], set[str]] = {} + rows: dict[tuple[str, int], dict[str, Any]] = {} + + for index, rule in enumerate(case.get("expect", [])): + where = f"case '{name}' rule #{index}" + metric = _select_metric(rule, metrics, where) + view = _select_view(rule, metric, where) + view_kind = view.get("view") if view else None + items = view.get(_VIEW_ITEMS[view_kind], []) if view_kind else [] + item = _select_one(items, rule["find"], where) if "find" in rule else None + target = item if item is not None else view if view is not None else metric + + if item is not None and view_kind is not None: + identity = (view_kind, id(item)) + rows[identity] = item + checked.setdefault(identity, set()).update(_asserted_fields(rule)) if "equal" in rule: - if it is None: - raise ExpectError(f"{where}: `equal` requires a `find` that selects one row") - entry["asserted"] |= {k for k in rule["equal"] if k in _STAT_FIELDS} - for field, exp in rule["equal"].items(): - got = it.get(field) - if not _values_equal(got, exp): - raise ExpectError(f"{where}: {field}: expected {exp!r}, got {got!r}") + if target is None: + raise ExpectError(f"{where}: `equal` requires `metric`, `view`, or `find`") + for field, expected in rule["equal"].items(): + got = target.get(field) + if not _values_equal(got, expected): + raise ExpectError(f"{where}: {field}: expected {expected!r}, got {got!r}") + elif "contains" in rule: + if target is None: + raise ExpectError(f"{where}: `contains` requires `metric`, `view`, or `find`") + for field, selector in rule["contains"].items(): + values = target.get(field) + if not isinstance(values, list) or not any(_matches(value, selector) for value in values): + raise ExpectError(f"{where}: {field} contains no match for {selector!r}") + elif "nonempty" in rule: + if target is None: + raise ExpectError(f"{where}: `nonempty` requires `metric`, `view`, or `find`") + for field in rule["nonempty"]: + if not target.get(field): + raise ExpectError(f"{where}: {field} is empty") elif "assert" in rule: - # CANONICAL source of the CEL `assert` bindings (documented in the - # yaml-rig FEATURE, DESIGN expect-engine component, README, and the - # /metric-test skill). `it` is None unless this rule had a `find`. bindings = { - "it": it, + "it": item, "items": items, - "result": result, - "results": results, + "view": view, + "metric": metric, + "metrics": metrics, "status": http_status, } - if it is not None: - entry["asserted"] |= _stat_refs_in_cel(rule["assert"]) try: - ok = _eval_cel(rule["assert"], bindings) - except Exception as e: # noqa: BLE001 - surface CEL errors as rule failures - raise ExpectError(f"{where}: CEL error in {rule['assert']!r}: {e}") from e - if not ok: + passed = _eval_cel(rule["assert"], bindings) + except Exception as error: + raise ExpectError(f"{where}: CEL error in {rule['assert']!r}: {error}") from error + if not passed: raise ExpectError(f"{where}: assert failed: {rule['assert']}") else: - raise ExpectError(f"{where}: rule must have `equal` or `assert`") + raise ExpectError(f"{where}: rule must have `equal`, `contains`, `nonempty`, or `assert`") - # No-unasserted-stat gate (runs after every explicit rule passed): every stat - # field a matched row CARRIES must have been asserted somewhere in this case. - for e in checked.values(): - present = {f for f in _STAT_FIELDS if f in e["row"]} - missing = present - e["asserted"] + for identity, asserted in checked.items(): + view_kind, _ = identity + required = _REQUIRED_VIEW_FIELDS[view_kind] + missing = required - asserted if missing: raise ExpectError( - f"{e['where']}: row for find {e['find']} returns {sorted(missing)} " - f"but no expect rule asserts them — every bullet stat the API " - f"returns must be checked (add to `equal` or a CEL `assert`)." + f"case '{name}': {view_kind} row leaves {sorted(missing)} unasserted" ) diff --git a/src/ingestion/tests/e2e/lib/fixture_loader.py b/src/ingestion/tests/e2e/lib/fixture_loader.py index 3820b9a28..afca400a0 100644 --- a/src/ingestion/tests/e2e/lib/fixture_loader.py +++ b/src/ingestion/tests/e2e/lib/fixture_loader.py @@ -42,6 +42,7 @@ class TestYaml: path: Path # table fqn ("bronze_m365.email_activity") -> list of resolved+padded rows bronze: dict[str, list[dict]] = field(default_factory=dict) + schemas: dict[str, dict] = field(default_factory=dict) cases: list[dict] = field(default_factory=list) @property @@ -81,6 +82,7 @@ def load(path: Path, *, schemas_dir: Path | None = None) -> TestYaml: if not isinstance(bronze_doc, dict): raise FixtureError(f"{path}: `bronze` must be a mapping of table → records") bronze: dict[str, list[dict]] = {} + schemas: dict[str, dict] = {} for table, rows in bronze_doc.items(): if not isinstance(rows, list): raise FixtureError(f"{path}: bronze.{table} must be a list of records") @@ -101,6 +103,7 @@ def load(path: Path, *, schemas_dir: Path | None = None) -> TestYaml: except schema_validator.SchemaError as e: raise FixtureError(f"{path}: bronze.{table}[{idx}]: {e}") from e bronze[table] = resolved + schemas[table] = schema cases = doc["cases"] if not isinstance(cases, list) or not cases: @@ -109,7 +112,13 @@ def load(path: Path, *, schemas_dir: Path | None = None) -> TestYaml: if not isinstance(case, dict) or "request" not in case or "expect" not in case: raise FixtureError(f"{path}: cases[{i}] must be a mapping with `request` and `expect`") - return TestYaml(name=path.name[: -len(".test.yaml")], path=path, bronze=bronze, cases=cases) + return TestYaml( + name=path.name[: -len(".test.yaml")], + path=path, + bronze=bronze, + schemas=schemas, + cases=cases, + ) def _find_schemas_dir(test_path: Path) -> Path: diff --git a/src/ingestion/tests/e2e/lib/metric_coverage.py b/src/ingestion/tests/e2e/lib/metric_coverage.py index 36e0bad0a..4da0a06dc 100644 --- a/src/ingestion/tests/e2e/lib/metric_coverage.py +++ b/src/ingestion/tests/e2e/lib/metric_coverage.py @@ -1,457 +1,189 @@ -"""Metric-coverage gate: every metric_key the catalog exposes has its value tested. - -Cross-checks, **by metric_key**, the metric universe — read over HTTP from a -running analytics (`POST /v1/catalog/get_metrics`: the enabled product -metric_keys, each a `.` seeded by the analytics -migrations) — against the metric_keys whose VALUE the tests assert -(`find: {metric_key: …}` paired with `equal`/`assert` in the same rule). Binary -verdict per metric_key: - - • value-asserted by a test → PASS - • skip-listed (SKIP_LIST below) → PASS (baseline) - • neither → FAIL (a number nobody validates) - -Catalog keys are dotted (`collab_bullet_rows.m365_emails_sent`); a test asserts -either the bare response key (`m365_emails_sent`, the bullet paths) or the full -dotted key (`collab_person_counter_daily.messages_sent`, the unified-metrics -path). A dotted assertion matches the catalog directly; a bare one maps to its -dotted key by the column suffix, which is unique across the catalog (a future -collision raises — see `CoverageReport.__post_init__`). - -The skip list is the accepted baseline — inline `SKIP_LIST` (single source of -truth, no side-car file). Kept honest: a STALE entry (key no longer in the -catalog) or a REDUNDANT one (now value-tested) also fails. PASS iff no FAILs. - -This module never spawns analytics. CI: the `metric-coverage-gate` job reads -the universe from `--universe-file catalog_metrics.json` — the artifact the e2e -run collects (lib/collect_metrics.py) — so no app boot. Locally: -`./e2e.sh test` then `./e2e.sh gates`. Ad hoc against a running API: -`ANALYTICS_URL=http://… python3 lib/metric_coverage.py [--md]`. -""" - from __future__ import annotations import argparse import json -import os +import re import sys from dataclasses import dataclass, field from pathlib import Path import yaml -# The tenant header the API requires (mirrors lib.config.TENANT_HEADER). Any -# non-nil tenant resolves the middleware; the catalog rows are tenant-NULL -# (global), so `get_metrics` returns them for any resolved tenant. -TENANT_HEADER = "X-Insight-Tenant-Id" -DEFAULT_TENANT_ID = "00000000-0000-0000-0000-000000000001" -# lib/metric_coverage.py -> lib/ -> e2e/ _E2E_ROOT = Path(__file__).resolve().parents[1] METRICS_DIR = _E2E_ROOT / "metrics" -_WHERE = "SKIP_LIST / SKIP_TABLES in lib/metric_coverage.py" - -# ── SKIP RULES (the accepted baseline; single source of truth) ─────────────── -# A served metric_key neither value-asserted by a test nor skipped here FAILS the -# gate. Two layers, both keyed by the catalog's `
.`: -# -# SKIP_TABLES — a whole storage table (vector) with NO connector in the e2e rig: -# EVERY key under it is auto-skipped, so a new column needs no hand-edit. -# SKIP_LIST — per-key skips for MIXED tables (the vector also has tested or -# differently-blocked keys). `reachable — …` rows are the actionable backlog. -# -# The reason is shown verbatim as the skip's status, so keep it a concise phrase. -# Hygiene (FAIL): an explicit SKIP_LIST entry that is now value-tested, or no -# longer a catalog key. (A table-covered key becoming tested is fine — it's just -# covered; the table rule still covers the rest of the vector.) -SKIP_TABLES: dict[str, str] = { - "ai_bullet_rows": "needs Cursor/Claude/ChatGPT connector", - "ai_person_counter_daily": "needs Cursor/Claude/ChatGPT connector", - "code_quality_bullet_rows": "needs Bitbucket/CI connector", - "crm_bullet_rows": "needs HubSpot connector", - "git_bullet_rows": "needs Bitbucket connector", - "ic_kpis": "composite KPI — needs Cursor+Bitbucket", - "support_bullet_rows": "needs Zendesk connector", - "wiki_bullet_rows": "needs Confluence/Outline connector", +_VIEW_FIELDS = { + "period": {"value"}, + "peer": {"target_value", "p25", "median", "p75", "min", "max", "n"}, + "timeseries": {"points"}, + "breakdown": {"value"}, + "histogram": {"bins"}, } -SKIP_LIST: list[tuple[str, str]] = [ - # collab_bullet_rows — Slack has no connector - ("collab_bullet_rows.slack_active_days", "needs Slack connector"), - ("collab_bullet_rows.slack_channel_posts", "needs Slack connector"), - ("collab_bullet_rows.slack_messages_sent", "needs Slack connector"), - ("collab_bullet_rows.slack_msgs_per_active_day", "needs Slack connector"), - ("collab_bullet_rows.slack_dm_ratio", "needs Slack connector"), - # collab_person_counter_daily — collaboration messaging (M365 Teams / Zulip) - ("collab_person_counter_daily.channel_posts", "reachable — Teams/Zulip fixtures exist"), - ("collab_person_counter_daily.messages_sent", "reachable — Teams/Zulip fixtures exist"), - # task_delivery_bullet_rows — Jira fixtures exist (reachable backlog) - ("task_delivery_bullet_rows.avg_slip", "reachable — Jira fixtures exist"), - ("task_delivery_bullet_rows.on_time_delivery", "reachable — Jira fixtures exist"), - ("task_delivery_bullet_rows.overrun_ratio", "reachable — Jira fixtures exist"), - ("task_delivery_bullet_rows.scope_completion", "reachable — Jira fixtures exist"), - ("task_delivery_bullet_rows.scope_creep", "reachable — Jira fixtures exist"), -] - - -def suffix(metric_key: str) -> str: - """The `` part of a `
.` catalog key (or the bare key).""" - return metric_key.split(".", 1)[-1] - - -def resolve_skips(universe: dict[str, str]) -> tuple[dict[str, str], set[str]]: - """`({metric_key: reason}, explicit_keys)` — the skip baseline for `universe`. - - SKIP_LIST is per-key; SKIP_TABLES auto-skips every universe key under a - connector-less vector. `explicit_keys` (the SKIP_LIST keys) is what drives the - redundant/stale hygiene — a table-covered key is not hand-maintained, so it - never goes redundant or stale. Raises on a duplicate SKIP_LIST key, or one - whose table is already a SKIP_TABLES rule (doubly-specified). - """ - explicit: dict[str, str] = {} - for key, reason in SKIP_LIST: - if key in explicit: - raise ValueError(f"duplicate metric_key in SKIP_LIST: {key}") - if _vector(key) in SKIP_TABLES: - raise ValueError( - f"SKIP_LIST key {key} is already covered by SKIP_TABLES[{_vector(key)!r}] — " - f"drop the per-key entry." - ) - explicit[key] = reason - resolved = dict(explicit) - for k in universe: - if _vector(k) in SKIP_TABLES: - resolved.setdefault(k, SKIP_TABLES[_vector(k)]) - return resolved, set(explicit) - - -def _universe_from_body(body: object) -> dict[str, str]: - """Parse a `POST /v1/catalog/get_metrics` body into `{metric_key: label}`. - - Response shape: `{"metrics": [{"metric_key", "label", ...}]}`. - """ - metrics = body.get("metrics", []) if isinstance(body, dict) else [] - return {str(m["metric_key"]): str(m.get("label", "")) for m in metrics} - - -def universe_from_url(base_url: str, tenant_id: str = DEFAULT_TENANT_ID) -> dict[str, str]: - """`{metric_key: label}` from `POST {base_url}/v1/catalog/get_metrics` — the - enabled product metric_keys (dotted `
.`). - - Sourced from the API (not a raw `metric_catalog` SELECT) so the gate checks - the contract consumers see; the endpoint already returns exactly the enabled - catalog rows. - """ - import httpx # local import: keeps the pure logic importable without httpx - with httpx.Client(base_url=base_url, timeout=30.0, headers={TENANT_HEADER: tenant_id}) as c: - resp = c.post("/v1/catalog/get_metrics", json={}) - resp.raise_for_status() - body = resp.json() - return _universe_from_body(body) +@dataclass(frozen=True) +class MetricDefinition: + metric_key: str + label: str + computation: str + dimensions: tuple[str, ...] + peer_cohort_key: str | None - -def universe_from_file(path: str | Path) -> dict[str, str]: - """`{metric_key: label}` from a saved `POST /v1/catalog/get_metrics` response - — the `catalog_metrics.json` artifact the e2e run collects. Lets the CI gate - analyse the universe from a file without booting analytics. - """ - return _universe_from_body(json.loads(Path(path).read_text(encoding="utf-8"))) - - -def asserted_keys_from_tests(metrics_dir: Path = METRICS_DIR) -> dict[str, set[str]]: - """`{metric_key: {test files}}` — keys whose VALUE a test checks (bare or dotted). - - A key counts only when a `find: {metric_key: …}` selector is paired with an - `equal` or `assert` in the SAME expect rule (i.e. the value is validated, not - merely selected). Plain `safe_load` — a metric_key is always a literal. - - Recurses (`rglob`) so nested fixtures are seen — the rig discovers tests as - `metrics/**/*.test.yaml` (`lib.fixture_loader.discover_tests`); a flat glob - here would miss a nested fixture and flag its key as falsely uncovered. - """ - out: dict[str, set[str]] = {} - for path in sorted(metrics_dir.rglob("*.test.yaml")): - doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - for case in doc.get("cases") or []: - for rule in case.get("expect") or []: - mk = (rule.get("find") or {}).get("metric_key") - if mk and ("equal" in rule or "assert" in rule): - out.setdefault(str(mk), set()).add(path.name) - return out + @property + def required_views(self) -> set[str]: + views = {"period", "timeseries"} + if self.peer_cohort_key: + views.add("peer") + if self.dimensions: + views.add("breakdown") + if self.computation == "median": + views.add("histogram") + return views @dataclass class CoverageReport: - universe: dict[str, str] # metric_key (dotted) -> label - asserted: dict[str, set[str]] # bare metric_key -> {files} - skips: dict[str, str] # metric_key (dotted) -> reason (resolved: SKIP_LIST + SKIP_TABLES) - explicit_skips: set[str] = field(default_factory=set) # hand-maintained SKIP_LIST keys (drive hygiene) - - # Derived sets (dotted metric_keys unless noted), populated in __post_init__. - covered: set[str] = field(default_factory=set) # PASS (value-tested) - skipped_active: set[str] = field(default_factory=set) # PASS (baseline) - uncovered: set[str] = field(default_factory=set) # FAIL (a number nobody validates) - redundant_skips: set[str] = field(default_factory=set) # FAIL (skip-listed AND tested) - stale_skips: set[str] = field(default_factory=set) # FAIL (skip for a non-existent key) - stale_tables: set[str] = field(default_factory=set) # FAIL (SKIP_TABLES vector no longer in the catalog) - unknown_asserted: set[str] = field(default_factory=set) # FAIL (bare key, no catalog match) + universe: dict[str, MetricDefinition] + asserted: dict[str, dict[str, set[str]]] + requested: set[str] + missing: dict[str, set[str]] = field(default_factory=dict) + unknown_asserted: set[str] = field(default_factory=set) + unknown_requested: set[str] = field(default_factory=set) def __post_init__(self) -> None: - # Map the catalog's dotted keys by their unique column suffix so a test's - # bare assertion key resolves to one catalog key. - by_suffix: dict[str, str] = {} - for k in self.universe: - s = suffix(k) - if s in by_suffix: - raise ValueError( - f"catalog suffix collision {s!r} ({by_suffix[s]} vs {k}) — " - f"bare→dotted suffix mapping is unsafe; scope by metric_id instead." - ) - by_suffix[s] = k - - for bare in self.asserted: - # A fully-qualified dotted key (the response `metric_key` of the - # unified-metrics path, e.g. `collab_person_counter_daily.messages_sent`) - # matches the catalog directly; bare column keys resolve via the - # unique suffix. Without the direct match, dotted assertions were - # miscounted as unasserted and their keys flagged MISSING. - full = bare if bare in self.universe else by_suffix.get(bare) - (self.covered if full else self.unknown_asserted).add(full or bare) - - u, s = set(self.universe), set(self.skips) - # redundant/stale apply only to hand-maintained SKIP_LIST entries; a - # table-covered key that gets tested is simply covered (its SKIP_TABLES - # rule stays valid for the rest of the vector). - self.redundant_skips = self.explicit_skips & self.covered - self.stale_skips = self.explicit_skips - u - # A whole SKIP_TABLES vector can also go stale: if the catalog serves NO - # key under it, the table rule is dead and must be removed. (A table whose - # keys are all now tested is NOT flagged — the rule still exists to - # auto-cover future keys of that connector-less vector.) - self.stale_tables = {t for t in SKIP_TABLES if not any(_vector(k) == t for k in u)} - self.skipped_active = (s & u) - self.covered - self.uncovered = u - self.covered - s + universe_keys = set(self.universe) + self.unknown_asserted = set(self.asserted) - universe_keys + self.unknown_requested = self.requested - universe_keys + for key, definition in self.universe.items(): + covered = set(self.asserted.get(key, {})) + absent = definition.required_views - covered + if absent: + self.missing[key] = absent @property def passed(self) -> bool: - return not ( - self.uncovered - or self.redundant_skips - or self.stale_skips - or self.stale_tables - or self.unknown_asserted - ) + return not self.missing and not self.unknown_asserted and not self.unknown_requested - def files_for(self, full_key: str) -> set[str]: - return self.asserted.get(suffix(full_key), set()) - -def build_report(universe: dict[str, str], metrics_dir: Path = METRICS_DIR) -> CoverageReport: - """Assemble the report. `universe` comes from `universe_from_url` (the catalog - metric_keys the API serves); asserted + skips are local to the rig.""" - resolved, explicit = resolve_skips(universe) - return CoverageReport( - universe=universe, - asserted=asserted_keys_from_tests(metrics_dir), - skips=resolved, - explicit_skips=explicit, - ) - - -def gate_violations(r: CoverageReport) -> list[str]: - """Human-readable FAIL reasons. Empty list == gate PASS.""" - out: list[str] = [] - for k in sorted(r.uncovered): - out.append( - f"FAIL `{k}` — served by the catalog but no test asserts its value and it is " - f"not skip-listed. Add a `find: {{metric_key: {suffix(k)}}}` + `equal`/`assert`, " - f"or add it to {_WHERE}." - ) - for k in sorted(r.redundant_skips): - files = ", ".join(sorted(r.files_for(k))) - out.append( - f"FAIL `{k}` — skip-listed but now value-tested by [{files}]. Remove its entry " - f"from {_WHERE}." - ) - for k in sorted(r.stale_skips): - out.append( - f"FAIL `{k}` — skip-listed but no longer a catalog metric_key (removed/renamed). " - f"Remove it from {_WHERE}." - ) - for t in sorted(r.stale_tables): - out.append( - f"FAIL SKIP_TABLES[`{t}`] — the catalog serves no metric_key under this vector " - f"(connector/table removed or renamed). Remove the table rule from {_WHERE}." - ) - for bare in sorted(r.unknown_asserted): - files = ", ".join(sorted(r.asserted[bare])) - out.append( - f"FAIL `{bare}` — asserted by [{files}] but is not a catalog metric_key (typo, or " - f"an unseeded key that matches 0 rows)." +def universe_from_file(path: str | Path) -> dict[str, MetricDefinition]: + body = json.loads(Path(path).read_text(encoding="utf-8")) + metrics = body.get("metrics", []) if isinstance(body, dict) else [] + return { + str(metric["metric_key"]): MetricDefinition( + metric_key=str(metric["metric_key"]), + label=str(metric.get("label", "")), + computation=str(metric["computation"]), + dimensions=tuple(str(value) for value in metric.get("dimensions", [])), + peer_cohort_key=metric.get("peer_cohort_key"), ) - return out - - -# Friendly vector names for the storage tables (display only). -_VECTOR_NAMES = { - "collab_bullet_rows": "Collaboration", - "task_delivery_bullet_rows": "Task Delivery", - "ai_bullet_rows": "AI Adoption", - "git_bullet_rows": "Git Activity", - "code_quality_bullet_rows": "Code Quality", - "crm_bullet_rows": "CRM / Sales", - "support_bullet_rows": "Support", - "wiki_bullet_rows": "Wiki / Knowledge", - "ic_kpis": "IC KPIs (heatmap)", -} - - -def _vector(metric_key: str) -> str: - return metric_key.split(".", 1)[0] - - -def _vector_name(table: str) -> str: - return _VECTOR_NAMES.get(table, table) - - -def _by_table(keys) -> dict[str, list[str]]: - groups: dict[str, list[str]] = {} - for k in keys: - groups.setdefault(_vector(k), []).append(k) - return groups - - -def _pct(n: int, d: int) -> str: - return f"{round(100 * n / d)}%" if d else "—" - - -def _is_reachable(reason: str) -> bool: - """A skip whose fixtures already exist — the actionable backlog.""" - return reason.lower().startswith("reachable") - - -def _skips_by_reason(r: CoverageReport) -> list[tuple[str, int]]: - """`[(reason, count)]` over active skips, most-common first.""" - counts: dict[str, int] = {} - for k in r.skipped_active: - counts[r.skips[k]] = counts.get(r.skips[k], 0) + 1 - return sorted(counts.items(), key=lambda x: (-x[1], x[0])) + for metric in metrics + } + + +def _asserted_fields(rule: dict) -> set[str]: + fields = set((rule.get("equal") or {}).keys()) + fields.update((rule.get("contains") or {}).keys()) + fields.update(rule.get("nonempty") or []) + expression = rule.get("assert") or "" + fields.update(re.findall(r"\bit\.([a-zA-Z_][a-zA-Z0-9_]*)\b", expression)) + fields.update(re.findall(r"\bit\[['\"]([a-zA-Z_][a-zA-Z0-9_]*)['\"]\]", expression)) + return fields + + +def _covers_view(rule: dict, view: str) -> bool: + required = _VIEW_FIELDS[view] + if "find" in rule: + return required <= _asserted_fields(rule) + expression = rule.get("assert") or "" + if view == "timeseries": + return ".points" in expression + if view == "breakdown": + return ( + ".dimensions" in expression and ".value" in expression + ) or bool(re.search(r"size\(items\)\s*==\s*0", expression)) + if view == "histogram": + return ".bins" in expression + return False + + +def coverage_from_tests( + metrics_dir: Path = METRICS_DIR, +) -> tuple[dict[str, dict[str, set[str]]], set[str]]: + asserted: dict[str, dict[str, set[str]]] = {} + requested: set[str] = set() + for path in sorted(metrics_dir.rglob("*.test.yaml")): + document = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + for case in document.get("cases") or []: + body = (case.get("request") or {}).get("body") or {} + for metric in body.get("metrics") or []: + key = metric.get("metric_key") + if key: + requested.add(str(key)) + for rule in case.get("expect") or []: + key = rule.get("metric") + view = rule.get("view") + if not key or view not in _VIEW_FIELDS: + continue + if _covers_view(rule, view): + asserted.setdefault(str(key), {}).setdefault(view, set()).add(path.name) + return asserted, requested + + +def build_report( + universe: dict[str, MetricDefinition], metrics_dir: Path = METRICS_DIR +) -> CoverageReport: + asserted, requested = coverage_from_tests(metrics_dir) + return CoverageReport(universe=universe, asserted=asserted, requested=requested) + + +def gate_violations(report: CoverageReport) -> list[str]: + violations = [ + f"FAIL `{key}` — missing assertions for views: {', '.join(sorted(views))}" + for key, views in sorted(report.missing.items()) + ] + violations.extend( + f"FAIL `{key}` — asserted but absent from the builtin metric registry" + for key in sorted(report.unknown_asserted) + ) + violations.extend( + f"FAIL `{key}` — requested but absent from the builtin metric registry" + for key in sorted(report.unknown_requested) + ) + return violations -def render_markdown(r: CoverageReport) -> str: - """Markdown report: a per-vector summary + the reachable backlog up top, then - the full per-key detail (collapsed), then a skip-list-hygiene footer.""" - cov, skp, tot, miss = ( - len(r.covered), len(r.skipped_active), len(r.universe), len(r.uncovered), - ) - out = [ - "# Metric coverage — by metric_key", +def render_markdown(report: CoverageReport) -> str: + covered = len(report.universe) - len(report.missing) + lines = [ + "# Unified builtin metric coverage", + "", + f"**Gate: {'PASS' if report.passed else 'FAIL'}.** {covered}/{len(report.universe)} metrics cover every supported view.", "", - f"**Gate: {'✅ PASS' if r.passed else '❌ FAIL'}.** " - f"{cov}/{tot} numbers validated ({_pct(cov, tot)}) · {skp} baseline-skipped · " - f"**{miss} missing**.", + "| metric | computation | required views | covered views |", + "|---|---|---|---|", ] - - # ── Per-vector summary ─────────────────────────────────────────────────── - tables = _by_table(r.universe) - out += ["", "## Coverage by vector", "", - "| vector | tested | skipped | missing | coverage |", - "|---|--:|--:|--:|--:|"] - for t in sorted(tables, key=lambda x: (-sum(1 for k in tables[x] if k in r.covered), x)): - keys = tables[t] - c = sum(1 for k in keys if k in r.covered) - s = sum(1 for k in keys if k in r.skipped_active) - m = sum(1 for k in keys if k in r.uncovered) - out.append(f"| {_vector_name(t)} | {c} | {s} | {m} | {_pct(c, len(keys))} |") - out.append(f"| **Total** | **{cov}** | **{skp}** | **{miss}** | **{_pct(cov, tot)}** |") - - # ── Why the skips are skipped ──────────────────────────────────────────── - by_reason = _skips_by_reason(r) - if by_reason: - out += ["", "## Skipped — by reason", "", "| reason | keys |", "|---|--:|"] - for reason, n in by_reason: - out.append(f"| {reason} | {n} |") - - # ── Reachable backlog (fixtures exist — just write the assertion) ───────── - backlog = sorted(k for k in r.skipped_active if _is_reachable(r.skips[k])) - if backlog: - out += ["", f"## Reachable now — backlog ({len(backlog)})", - "_Fixtures already exist; each just needs a `find:`+`equal` assertion in a test._", - ""] - for k in backlog: - out.append(f"- **{r.universe[k] or suffix(k)}** — `{suffix(k)}` ({_vector_name(_vector(k))})") - - # ── Full per-key detail (collapsed) ────────────────────────────────────── - out += ["", "
Per-key detail (all " - f"{tot})", ""] - for t in sorted(tables): - keys = sorted(tables[t]) - c = sum(1 for k in keys if k in r.covered) - out += ["", f"### {_vector_name(t)} (`{t}`) — {c}/{len(keys)}", "", - "| status | metric | key | detail |", "|---|---|---|---|"] - for k in keys: - col, label = suffix(k), (r.universe[k] or suffix(k)) - if k in r.uncovered: - out.append(f"| ❌ MISSING | {label} | `{col}` | no value assertion, not skip-listed |") - elif k in r.covered: - out.append(f"| ✅ tested | {label} | `{col}` | {', '.join(sorted(r.files_for(k)))} |") - else: - out.append(f"| ⏭️ {r.skips[k]} | {label} | `{col}` | |") - out += ["", "
"] - - # ── Skip-list hygiene (these also fail the gate) ───────────────────────── - hygiene: list[str] = [] - for k in sorted(r.redundant_skips): - hygiene.append(f"- `{k}` skip-listed but now tested by [{', '.join(sorted(r.files_for(k)))}]; remove from SKIP_LIST.") - for k in sorted(r.stale_skips): - hygiene.append(f"- `{k}` skip-listed but no longer in the catalog; remove from SKIP_LIST.") - for bare in sorted(r.unknown_asserted): - hygiene.append(f"- `{bare}` asserted by [{', '.join(sorted(r.asserted[bare]))}] is not a catalog metric_key (typo/unseeded).") - if hygiene: - out += ["", "## Skip-list issues (also fail the gate)", *hygiene] - return "\n".join(out) + "\n" + for key, definition in sorted(report.universe.items()): + covered_views = sorted(report.asserted.get(key, {})) + lines.append( + f"| `{key}` | {definition.computation} | {', '.join(sorted(definition.required_views))} | {', '.join(covered_views)} |" + ) + violations = gate_violations(report) + if violations: + lines.extend(["", "## Violations", "", *[f"- {violation}" for violation in violations]]) + return "\n".join(lines) + "\n" def main(argv: list[str] | None = None) -> int: - """CLI: print the coverage table/report; exit non-zero on any gate failure. - - Two universe sources: - --universe-file the artifact the e2e run collects - (CI gate — no analytics boot) - else $ANALYTICS_URL fetch live (local standalone runs) - This module never spawns analytics itself. - """ - p = argparse.ArgumentParser(description="Metric-coverage gate (by metric_key).") - p.add_argument( - "--universe-file", - help="catalog_metrics.json (a saved POST /v1/catalog/get_metrics response); " - "default: fetch from $ANALYTICS_URL", - ) - args = p.parse_args(argv) - - if args.universe_file: - universe = universe_from_file(args.universe_file) - else: - url = os.environ.get("ANALYTICS_URL") - if not url: - print( - "metric coverage: pass --universe-file or set " - "ANALYTICS_URL to a running analytics.", - file=sys.stderr, - ) - return 2 - universe = universe_from_url(url, os.environ.get("ANALYTICS_TENANT_ID", DEFAULT_TENANT_ID)) - - report = build_report(universe) - if not report.universe: - print( - "metric coverage: empty universe — the catalog isn't seeded (live) or the " - "collected catalog_metrics.json is empty.", - file=sys.stderr, - ) - return 1 - print(render_markdown(report)) + parser = argparse.ArgumentParser() + parser.add_argument("--universe-file", required=True) + parser.add_argument("--metrics-dir", type=Path, default=METRICS_DIR) + parser.add_argument("--md", action="store_true") + args = parser.parse_args(argv) + report = build_report(universe_from_file(args.universe_file), args.metrics_dir) + output = render_markdown(report) + print(output, end="") + for violation in gate_violations(report): + print(violation, file=sys.stderr) return 0 if report.passed else 1 diff --git a/src/ingestion/tests/e2e/meta/test_expect_engine.py b/src/ingestion/tests/e2e/meta/test_expect_engine.py index 98a8d4096..c462b4e2c 100644 --- a/src/ingestion/tests/e2e/meta/test_expect_engine.py +++ b/src/ingestion/tests/e2e/meta/test_expect_engine.py @@ -1,7 +1,3 @@ -"""Unit tests for expect_engine (DoD cpt-bronze-to-api-e2e-dod-yaml-expect-engine). -Pure: no ClickHouse / dbt. Requires `cel-python`. -""" - from __future__ import annotations import pytest @@ -12,17 +8,43 @@ pytestmark = pytest.mark.smoke -def _batch(): +def _response(): return { - "results": [ + "metrics": [ { - "id": "collab", - "status": "ok", - "items": [ - {"metric_key": "m365_emails_sent", "value": 40, "median": 20, - "range_min": 10, "range_max": 40}, - {"metric_key": "slack_dm_ratio", "value": None, - "median": None, "range_min": None, "range_max": None}, + "metric_key": "collab.emails_sent", + "computation": "sum", + "views": [ + { + "view": "period", + "values": [{"entity_id": "alice@example.com", "value": 40}], + }, + { + "view": "peer", + "values": [ + { + "entity_id": "alice@example.com", + "target_value": 40, + "p25": 10, + "median": 20, + "p75": 30, + "min": 5, + "max": 40, + "n": 5, + } + ], + }, + { + "view": "timeseries", + "bucket": "day", + "series": [ + { + "entity_id": "alice@example.com", + "dimensions": [], + "points": [{"bucket_start": "2026-01-01", "value": 40}], + } + ], + }, ], } ] @@ -34,73 +56,152 @@ def _case(expect): def test_full_pass(): - case = _case([ - {"assert": "status == 200"}, - {"in": "collab", "assert": "result.status == 'ok'"}, - {"in": "collab", "find": {"metric_key": "m365_emails_sent"}, - "equal": {"value": 40, "median": 20, "range_min": 10, "range_max": 40}}, - {"in": "collab", "assert": "size(items) == 2"}, - # Assert every stat this row carries — the no-unasserted-stat gate requires it. - {"in": "collab", "find": {"metric_key": "slack_dm_ratio"}, - "equal": {"value": None, "median": None, "range_min": None, "range_max": None}}, - ]) - evaluate_case(case, _batch(), 200) # no raise + case = _case( + [ + {"assert": "status == 200"}, + {"metric": "collab.emails_sent", "equal": {"computation": "sum"}}, + { + "metric": "collab.emails_sent", + "view": "period", + "find": {"entity_id": "alice@example.com"}, + "equal": {"value": 40}, + }, + { + "metric": "collab.emails_sent", + "view": "peer", + "find": {"entity_id": "alice@example.com"}, + "equal": { + "target_value": 40, + "p25": 10, + "median": 20, + "p75": 30, + "min": 5, + "max": 40, + "n": 5, + }, + }, + { + "metric": "collab.emails_sent", + "view": "timeseries", + "find": {"entity_id": "alice@example.com"}, + "equal": {"points": [{"bucket_start": "2026-01-01", "value": 40}]}, + }, + ] + ) + evaluate_case(case, _response(), 200) def test_equal_mismatch_fails(): - case = _case([{"in": "collab", "find": {"metric_key": "m365_emails_sent"}, - "equal": {"value": 99}}]) + case = _case( + [ + { + "metric": "collab.emails_sent", + "view": "period", + "find": {"entity_id": "alice@example.com"}, + "equal": {"value": 99}, + } + ] + ) with pytest.raises(ExpectError, match="value: expected 99"): - evaluate_case(case, _batch(), 200) + evaluate_case(case, _response(), 200) + + +def test_unknown_metric_fails(): + case = _case([{"metric": "collab.missing", "assert": "true"}]) + with pytest.raises(ExpectError, match="matched 0 metrics"): + evaluate_case(case, _response(), 200) + + +def test_unknown_view_fails(): + case = _case( + [{"metric": "collab.emails_sent", "view": "breakdown", "assert": "true"}] + ) + with pytest.raises(ExpectError, match="matched 0 views"): + evaluate_case(case, _response(), 200) def test_find_no_match_fails(): - case = _case([{"in": "collab", "find": {"metric_key": "nope"}, "equal": {"value": 1}}]) + case = _case( + [ + { + "metric": "collab.emails_sent", + "view": "period", + "find": {"entity_id": "nobody@example.com"}, + "equal": {"value": 1}, + } + ] + ) with pytest.raises(ExpectError, match="matched 0 rows"): - evaluate_case(case, _batch(), 200) - - -def test_unknown_result_id_fails(): - case = _case([{"in": "ghost", "assert": "true"}]) - with pytest.raises(ExpectError, match="no batch result with id 'ghost'"): - evaluate_case(case, _batch(), 200) - - -def test_assert_false_fails(): - case = _case([{"in": "collab", "assert": "size(items) == 99"}]) - with pytest.raises(ExpectError, match="assert failed"): - evaluate_case(case, _batch(), 200) - - -def test_cel_inequality_and_null(): - # Numeric inequalities cast with double() (CEL won't compare int to double); - # null compares directly. Each row asserts every stat it carries (value / - # median / range_min / range_max) so the no-unasserted-stat gate is satisfied. - case = _case([ - {"in": "collab", "find": {"metric_key": "m365_emails_sent"}, - "assert": "double(it.value) > 39.5 && double(it.value) < 40.5 && " - "double(it.median) == 20.0 && double(it.range_min) == 10.0 && " - "double(it.range_max) == 40.0"}, - {"in": "collab", "find": {"metric_key": "slack_dm_ratio"}, - "assert": "it.value == null && it.median == null && " - "it.range_min == null && it.range_max == null"}, - ]) - evaluate_case(case, _batch(), 200) - - -def test_find_is_exact_equality_on_any_field(): - # `find` matches exact field equality — including non-key fields, no operators. - # The matched row carries the four stats, so assert all of them too. - case = _case([{"in": "collab", "find": {"value": 40}, - "equal": {"metric_key": "m365_emails_sent", "value": 40, - "median": 20, "range_min": 10, "range_max": 40}}]) - evaluate_case(case, _batch(), 200) - - -def test_in_optional_with_single_result(): - # The batch has a single result, so `in` may be omitted. m365_emails_sent - # carries the four stats — assert all of them for the no-unasserted-stat gate. - case = _case([{"find": {"metric_key": "m365_emails_sent"}, - "equal": {"value": 40, "median": 20, - "range_min": 10, "range_max": 40}}]) - evaluate_case(case, _batch(), 200) # `in` omitted → sole result + evaluate_case(case, _response(), 200) + + +def test_unasserted_peer_fields_fail(): + case = _case( + [ + { + "metric": "collab.emails_sent", + "view": "peer", + "find": {"entity_id": "alice@example.com"}, + "equal": {"target_value": 40}, + } + ] + ) + with pytest.raises(ExpectError, match="leaves .* unasserted"): + evaluate_case(case, _response(), 200) + + +def test_cel_assertions_count_fields(): + case = _case( + [ + { + "metric": "collab.emails_sent", + "view": "period", + "find": {"entity_id": "alice@example.com"}, + "assert": "double(it.value) == 40.0", + } + ] + ) + evaluate_case(case, _response(), 200) + + +def test_nested_contains_passes(): + case = _case( + [ + { + "metric": "collab.emails_sent", + "view": "timeseries", + "find": {"entity_id": "alice@example.com"}, + "contains": {"points": {"bucket_start": "2026-01-01", "value": 40.0}}, + } + ] + ) + evaluate_case(case, _response(), 200) + + +def test_nested_contains_mismatch_fails(): + case = _case( + [ + { + "metric": "collab.emails_sent", + "view": "timeseries", + "find": {"entity_id": "alice@example.com"}, + "contains": {"points": {"value": 99}}, + } + ] + ) + with pytest.raises(ExpectError, match="contains no match"): + evaluate_case(case, _response(), 200) + + +def test_nonempty_passes(): + case = _case( + [ + { + "metric": "collab.emails_sent", + "view": "timeseries", + "find": {"entity_id": "alice@example.com"}, + "nonempty": ["points"], + } + ] + ) + evaluate_case(case, _response(), 200) diff --git a/src/ingestion/tests/e2e/meta/test_metric_coverage.py b/src/ingestion/tests/e2e/meta/test_metric_coverage.py new file mode 100644 index 000000000..6d672cf24 --- /dev/null +++ b/src/ingestion/tests/e2e/meta/test_metric_coverage.py @@ -0,0 +1,122 @@ +from pathlib import Path + +from lib.metric_coverage import MetricDefinition, build_report, coverage_from_tests + + +def _definition( + metric_key: str, + computation: str = "sum", + dimensions: tuple[str, ...] = (), +) -> MetricDefinition: + return MetricDefinition( + metric_key=metric_key, + label=metric_key, + computation=computation, + dimensions=dimensions, + peer_cohort_key="org_unit", + ) + + +def _write_fixture(path: Path, metric_key: str, extra_views: str = "") -> None: + path.write_text( + f"""spec_version: 1 +cases: + - name: coverage + request: + body: + metrics: + - metric_key: {metric_key} + expect: + - metric: {metric_key} + view: period + find: {{ entity_id: person@example.com }} + equal: {{ value: 1 }} + - metric: {metric_key} + view: peer + find: {{ entity_id: person@example.com }} + equal: {{ target_value: 1, p25: 1, median: 1, p75: 1, min: 1, max: 1, n: 5 }} + - metric: {metric_key} + view: timeseries + assert: "items.exists(s, size(s.points) > 0)" +{extra_views}""", + encoding="utf-8", + ) + + +def test_sum_metric_requires_period_peer_and_timeseries(tmp_path: Path) -> None: + key = "test.sum" + _write_fixture(tmp_path / "sum.test.yaml", key) + report = build_report({key: _definition(key)}, tmp_path) + assert report.passed + + +def test_dimensions_and_median_require_breakdown_and_histogram(tmp_path: Path) -> None: + key = "test.median" + _write_fixture( + tmp_path / "median.test.yaml", + key, + f""" - metric: {key} + view: breakdown + assert: "items.exists(v, size(v.dimensions) > 0 && v.value != null)" + - metric: {key} + view: histogram + assert: "items.exists(h, size(h.bins) > 0)" +""", + ) + report = build_report( + {key: _definition(key, computation="median", dimensions=("source",))}, + tmp_path, + ) + assert report.passed + + +def test_missing_and_unknown_metrics_fail(tmp_path: Path) -> None: + _write_fixture(tmp_path / "unknown.test.yaml", "test.unknown") + report = build_report({"test.expected": _definition("test.expected")}, tmp_path) + assert report.missing == { + "test.expected": {"period", "peer", "timeseries"} + } + assert report.unknown_asserted == {"test.unknown"} + assert report.unknown_requested == {"test.unknown"} + assert not report.passed + + +def test_incomplete_peer_fields_do_not_cover_peer(tmp_path: Path) -> None: + key = "test.peer" + _write_fixture(tmp_path / "peer.test.yaml", key) + text = (tmp_path / "peer.test.yaml").read_text(encoding="utf-8") + (tmp_path / "peer.test.yaml").write_text( + text.replace(", p25: 1, median: 1, p75: 1, min: 1, max: 1, n: 5", ""), + encoding="utf-8", + ) + asserted, _ = coverage_from_tests(tmp_path) + assert "peer" not in asserted[key] + + +def test_typed_collection_assertions_cover_views(tmp_path: Path) -> None: + key = "test.median" + _write_fixture( + tmp_path / "median.test.yaml", + key, + f""" - metric: {key} + view: breakdown + assert: "size(items) == 0" + - metric: {key} + view: histogram + find: {{ entity_id: person@example.com }} + nonempty: [bins] +""", + ) + text = (tmp_path / "median.test.yaml").read_text(encoding="utf-8") + (tmp_path / "median.test.yaml").write_text( + text.replace( + 'view: timeseries\n assert: "items.exists(s, size(s.points) > 0)"', + "view: timeseries\n find: { entity_id: person@example.com }\n contains: { points: { value: 1 } }", + ), + encoding="utf-8", + ) + report = build_report( + {key: _definition(key, computation="median", dimensions=("source",))}, + tmp_path, + ) + assert report.passed diff --git a/src/ingestion/tests/e2e/metrics/ai_active_ai_members.test.yaml b/src/ingestion/tests/e2e/metrics/ai_active_ai_members.test.yaml deleted file mode 100644 index b3c9b3a83..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_active_ai_members.test.yaml +++ /dev/null @@ -1,49 +0,0 @@ -spec_version: 1 -description: > - Metric: active_ai_members — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: any AI-tool daily usage report (here Cursor) — one row marks a user - • silver: deduped to one row per person/day across all AI tools - • gold: tool-agnostic "active" metric — value=1 for the requested person if - they used any AI tool that day; range_max = count of active members - in the team (median is 0 by rule) - - Team (median/range = the person's department): - alice · bob · carol all active → value 1, median 0, range [0, 3]. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - bronze_cursor.cursor_daily_usage: - # date 1767571200000 ms = 2026-01-05. Any active AI-tool row counts. - - $ref: templates/cursor_usage.yaml#/templates/alice - unique_key: cursor-alice-20260105 - date: 1767571200000 - - $ref: templates/cursor_usage.yaml#/templates/bob - unique_key: cursor-bob-20260105 - date: 1767571200000 - - $ref: templates/cursor_usage.yaml#/templates/carol - unique_key: cursor-carol-20260105 - date: 1767571200000 - -cases: - - name: active_ai_members — IC bullet (1 of 3 active members) - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: active_ai_members } - equal: { value: 1, median: 0, range_min: 0, range_max: 3 } diff --git a/src/ingestion/tests/e2e/metrics/ai_cursor_active.test.yaml b/src/ingestion/tests/e2e/metrics/ai_active_days.test.yaml similarity index 58% rename from src/ingestion/tests/e2e/metrics/ai_cursor_active.test.yaml rename to src/ingestion/tests/e2e/metrics/ai_active_days.test.yaml index 7c41d7650..d4ddcb999 100644 --- a/src/ingestion/tests/e2e/metrics/ai_cursor_active.test.yaml +++ b/src/ingestion/tests/e2e/metrics/ai_active_days.test.yaml @@ -1,6 +1,6 @@ spec_version: 1 description: > - Metric: cursor_active — IC Bullet AI (…0013), #1440. + Metric: ai.active_days. How it's computed (bronze → silver → gold): • bronze: Cursor daily per-developer usage report — one row marks a Cursor user • silver: deduped to one row per developer/day @@ -28,21 +28,29 @@ bronze: date: 1767571200000 cases: - - name: cursor_active — IC bullet (1 of 3 active members) + - name: AI active days request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key + entity: { type: person, ids: [alice@example.com] } + period: { from: "2026-01-01", to: "2026-01-31" } + metrics: + - metric_key: ai.active_days + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } expect: - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: cursor_active } - equal: { value: 1, median: 0, range_min: 0, range_max: 3 } + - metric: ai.active_days + view: period + find: { entity_id: alice@example.com } + equal: { value: 1 } + - metric: ai.active_days + view: peer + find: { entity_id: alice@example.com } + equal: { target_value: 1, p25: null, median: null, p75: null, min: null, max: null, n: 3 } + - metric: ai.active_days + view: timeseries + assert: "items.exists(s, s.entity_id == 'alice@example.com' && s.points.exists(p, p.bucket_start == '2026-01-05' && double(p.value) == 1.0))" diff --git a/src/ingestion/tests/e2e/metrics/ai_ai_loc_share2.test.yaml b/src/ingestion/tests/e2e/metrics/ai_ai_loc_share2.test.yaml deleted file mode 100644 index 3029e8b53..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_ai_loc_share2.test.yaml +++ /dev/null @@ -1,59 +0,0 @@ -spec_version: 1 -description: > - Metric: ai_loc_share2 — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: Cursor daily per-developer usage report (AI-accepted lines + total lines written) - • silver: deduped to one row per developer/day carrying both line counts - • gold: a reconstructed ratio — 100 × Σ AI-accepted lines ÷ Σ total lines - written, rounded to 0.1 (computed at serve time, not stored) - - Team (value = the requested person): - alice accepted 40 of 80 total lines → 100×40/80 = 50.0. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - bronze_cursor.cursor_daily_usage: - # date 1767571200000 ms = 2026-01-05. - - $ref: templates/cursor_usage.yaml#/templates/alice - unique_key: cursor-alice-20260105 - date: 1767571200000 - acceptedLinesAdded: 40 - totalLinesAdded: 80 - - $ref: templates/cursor_usage.yaml#/templates/alice # re-sync dup → ratio unchanged - unique_key: cursor-alice-20260105 - date: 1767571200000 - acceptedLinesAdded: 40 - totalLinesAdded: 80 - - $ref: templates/cursor_usage.yaml#/templates/bob - unique_key: cursor-bob-20260105 - date: 1767571200000 - acceptedLinesAdded: 20 - totalLinesAdded: 80 - - $ref: templates/cursor_usage.yaml#/templates/carol - unique_key: cursor-carol-20260105 - date: 1767571200000 - acceptedLinesAdded: 10 - totalLinesAdded: 40 - -cases: - - name: ai_loc_share2 — IC bullet (reconstructed share, alice 40/80 = 50.0) - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: ai_loc_share2 } - equal: { value: 50.0, median: 25.0, range_min: 25.0, range_max: 50.0 } diff --git a/src/ingestion/tests/e2e/metrics/ai_assistant_activity.test.yaml b/src/ingestion/tests/e2e/metrics/ai_assistant_activity.test.yaml new file mode 100644 index 000000000..c9c24f071 --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/ai_assistant_activity.test.yaml @@ -0,0 +1,69 @@ +spec_version: 1 +description: Unified assistant actions and chat conversations from Claude Enterprise. + +bronze: + bronze_bamboohr.employees: + - $ref: templates/people.yaml#/templates/alice + - $ref: templates/people.yaml#/templates/bob + - $ref: templates/people.yaml#/templates/carol + - $ref: templates/people.yaml#/templates/dave + - $ref: templates/people.yaml#/templates/erin + + bronze_claude_enterprise.claude_enterprise_users: + - { $ref: templates/claude_enterprise_users.yaml#/templates/alice, date: "2026-01-05", unique_key: assistant-alice, chat_conversation_count: 5, chat_message_count: 15, cowork_action_count: 10 } + - { $ref: templates/claude_enterprise_users.yaml#/templates/bob, date: "2026-01-05", unique_key: assistant-bob, chat_conversation_count: 10, chat_message_count: 30, cowork_action_count: 20 } + - { $ref: templates/claude_enterprise_users.yaml#/templates/carol, date: "2026-01-05", unique_key: assistant-carol, chat_conversation_count: 15, chat_message_count: 45, cowork_action_count: 30 } + - { $ref: templates/claude_enterprise_users.yaml#/templates/ent_user, user_email: dave@example.com, user_id: user-dave, date: "2026-01-05", unique_key: assistant-dave, chat_conversation_count: 20, chat_message_count: 60, cowork_action_count: 40 } + - { $ref: templates/claude_enterprise_users.yaml#/templates/ent_user, user_email: erin@example.com, user_id: user-erin, date: "2026-01-05", unique_key: assistant-erin, chat_conversation_count: 25, chat_message_count: 75, cowork_action_count: 50 } + +cases: + - name: AI assistant actions and chat conversations + request: + url: /v1/metric-results + method: POST + body: + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-01-01", to: "2026-01-31" } + metrics: + - metric_key: ai.assistant_actions + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool, surface] } + - metric_key: ai.chat_assistant_conversations + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool, surface] } + expect: + - assert: "status == 200" + - metric: ai.assistant_actions + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: ai.assistant_actions + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: ai.assistant_actions + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-01-05' && double(p.value) == 50.0))" + - metric: ai.assistant_actions + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && size(v.dimensions) == 2 && v.dimensions.exists(d, d.key == 'tool' && d.value == 'claude') && v.dimensions.exists(d, d.key == 'surface' && d.value == 'cowork') && double(v.value) == 50.0)" + - metric: ai.chat_assistant_conversations + view: period + find: { entity_id: erin@example.com } + equal: { value: 25 } + - metric: ai.chat_assistant_conversations + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 25, p25: 10, median: 15, p75: 20, min: 5, max: 25, n: 5 } + - metric: ai.chat_assistant_conversations + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-01-05' && double(p.value) == 25.0))" + - metric: ai.chat_assistant_conversations + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && size(v.dimensions) == 2 && v.dimensions.exists(d, d.key == 'tool' && d.value == 'claude') && v.dimensions.exists(d, d.key == 'surface' && d.value == 'chat') && double(v.value) == 25.0)" diff --git a/src/ingestion/tests/e2e/metrics/ai_chatgpt.test.yaml b/src/ingestion/tests/e2e/metrics/ai_assistant_messages.test.yaml similarity index 51% rename from src/ingestion/tests/e2e/metrics/ai_chatgpt.test.yaml rename to src/ingestion/tests/e2e/metrics/ai_assistant_messages.test.yaml index 43c7811ff..5cd17f269 100644 --- a/src/ingestion/tests/e2e/metrics/ai_chatgpt.test.yaml +++ b/src/ingestion/tests/e2e/metrics/ai_assistant_messages.test.yaml @@ -1,6 +1,6 @@ spec_version: 1 description: > - Metric: chatgpt — IC Bullet AI (…0013), #1440. + Metric: ai.assistant_messages. How it's computed (bronze → silver → gold): • bronze: ChatGPT daily per-user chat activity (message counts) • silver: deduped to one row per user/day carrying the chat-message count @@ -34,21 +34,33 @@ bronze: messages: 10 cases: - - name: chatgpt — IC bullet (alice value vs team median, dedup holds) + - name: AI assistant messages request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key + entity: { type: person, ids: [alice@example.com] } + period: { from: "2026-01-01", to: "2026-01-31" } + metrics: + - metric_key: ai.assistant_messages + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool, surface] } expect: - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: chatgpt } - equal: { value: 40, median: 20, range_min: 10, range_max: 40 } + - metric: ai.assistant_messages + view: period + find: { entity_id: alice@example.com } + equal: { value: 40 } + - metric: ai.assistant_messages + view: peer + find: { entity_id: alice@example.com } + equal: { target_value: 40, p25: null, median: null, p75: null, min: null, max: null, n: 3 } + - metric: ai.assistant_messages + view: timeseries + assert: "items.exists(s, s.entity_id == 'alice@example.com' && s.points.exists(p, p.bucket_start == '2026-01-05' && double(p.value) == 40.0))" + - metric: ai.assistant_messages + view: breakdown + assert: "items.exists(v, v.entity_id == 'alice@example.com' && size(v.dimensions) == 2 && v.dimensions.exists(d, d.key == 'tool' && d.value == 'chatgpt') && v.dimensions.exists(d, d.key == 'surface' && d.value == 'chat') && double(v.value) == 40.0)" diff --git a/src/ingestion/tests/e2e/metrics/ai_cc_active.test.yaml b/src/ingestion/tests/e2e/metrics/ai_cc_active.test.yaml deleted file mode 100644 index f08033709..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_cc_active.test.yaml +++ /dev/null @@ -1,86 +0,0 @@ -spec_version: 1 -description: > - Metric: cc_active — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: Claude Team daily per-user metrics — any active row marks a Claude Code user - • silver: class_ai_dev_usage (tool='claude_code'); one row per user/day - • gold: an ACTIVE-MARKER metric. ai_bullet_rows branch 3 emits ('cc_active', 1) - for any claude_code day. The served IC query collapses each person to - 1 if they had ANY cc_active row in the window (else NULL), then: - value = 1 for the requested person (active that window) - median = 0 (hardcoded — active markers carry no real median) - range_min = 0 (hardcoded) - range_max = count of DEPARTMENT members active in the window - i.e. the bullet renders "erin is 1 of N active members". - - Stat set: active markers return value/median/range_min/range_max (NO p25/p75/n). - The no-unasserted-stat gate is self-scoping, so the four asserted here suffice. - - Department: 5 Engineering members (alice, bob, carol, dave, erin), all sharing one - org_unit (Engineering). In the custom window (Nov 01 .. Dec 12) four members have a - Claude Code row — bob & erin on Dec 10, carol & dave on Nov 15 — so range_max = 4 - ("erin is 1 of 4 active members"). alice's only row is Dec 25, outside the window, - so she is a roster member but inactive here. The empty window (Jun) has no rows. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_claude_team.claude_team_code_metrics: - # Any active claude_code row counts (status='active' default); total_sessions - # is set to 1 only so each row carries some activity. - # alice — Dec 25 (OUTSIDE the custom window → roster member, inactive here) - - { $ref: templates/claude_team_usage.yaml#/templates/alice, metric_date: "2026-12-25", unique_key: cc-alice-20261225, total_sessions: 1 } - # bob — Dec 10 (in window) - - { $ref: templates/claude_team_usage.yaml#/templates/bob, metric_date: "2026-12-10", unique_key: cc-bob-20261210, total_sessions: 1 } - # carol — Nov 15 (in window) - - { $ref: templates/claude_team_usage.yaml#/templates/carol, metric_date: "2026-11-15", unique_key: cc-carol-20261115, total_sessions: 1 } - # dave — Nov 15 (in window) - - { $ref: templates/claude_team_usage.yaml#/templates/claude_team_metrics, email: dave@example.com, metric_date: "2026-11-15", unique_key: cc-dave-20261115, total_sessions: 1 } - # erin — Dec 10 (in window); the requested IC member - - { $ref: templates/claude_team_usage.yaml#/templates/claude_team_metrics, email: erin@example.com, metric_date: "2026-12-10", unique_key: cc-erin-20261210, total_sessions: 1 } - - { $ref: templates/claude_team_usage.yaml#/templates/claude_team_metrics, email: erin@example.com, metric_date: "2026-12-10", unique_key: cc-erin-20261210, total_sessions: 1 } # re-sync dup → active stays 1, not 2 - -cases: - # ── CUSTOM scope (Nov 01 .. Dec 12) — bob, carol, dave, erin active ────────── - - name: "cc_active — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-11-01' and metric_date le '2026-12-12'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: cc_active } - equal: { value: 1, median: 0, range_min: 0, range_max: 4 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "cc_active — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/ai_cc_lines.test.yaml b/src/ingestion/tests/e2e/metrics/ai_cc_lines.test.yaml deleted file mode 100644 index 0d784bfae..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_cc_lines.test.yaml +++ /dev/null @@ -1,91 +0,0 @@ -spec_version: 1 -description: > - Metric: cc_lines — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: Claude Team daily per-user metrics (accepted lines) - • silver: class_ai_dev_usage, deduped to one row per user/day (tool='claude_code'); - lines_added ← total_lines_accepted - • gold: ai_bullet_rows branch 3 emits ('cc_lines', lines_added) per day; the - served IC query SUMs the requested person's per-day accepted lines over - the window (value), and reports the DEPARTMENT distribution of those - per-person SUMs as median/range_min/range_max. - - NOTE on the served stat set: the IC Bullet AI query_ref (id …0013) is last - re-seeded by migration m20260618_000001 (cc_overage), which carries only - value/median/range_min/range_max — NOT p25/p75/n. So these AI fixtures assert the - four stats the API actually returns; the no-unasserted-stat gate is self-scoping - and never requires p25/p75 on a row that does not carry them. - - Department: 5 Engineering members (alice, bob, carol, dave, erin), base rates - r ∈ {10,20,30,40,50}. Each member is seeded two lines-rows inside the custom window - (Nov 01 .. Dec 12) — one Nov 15 (2*r) + one Dec 10 (1*r) — so each per-person - window SUM = 3*r. The IC case requests erin (r=50) → value 150; the department of 5 - yields the median/range below. Cases: custom scope · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_claude_team.claude_team_code_metrics: - # Per member: Nov 15 = 2*r, Dec 10 = 1*r → window SUM = 3*r. - # ── alice, r=10 ───────────────────────────────────────────────────────────── - - { $ref: templates/claude_team_usage.yaml#/templates/alice, metric_date: "2026-11-15", unique_key: cc-alice-20261115, total_lines_accepted: 20 } # 2r - - { $ref: templates/claude_team_usage.yaml#/templates/alice, metric_date: "2026-12-10", unique_key: cc-alice-20261210, total_lines_accepted: 10 } # 1r - - { $ref: templates/claude_team_usage.yaml#/templates/alice, metric_date: "2026-12-10", unique_key: cc-alice-20261210, total_lines_accepted: 10 } # duplicate → dedup to 10, not 20 - # ── bob, r=20 ─────────────────────────────────────────────────────────────── - - { $ref: templates/claude_team_usage.yaml#/templates/bob, metric_date: "2026-11-15", unique_key: cc-bob-20261115, total_lines_accepted: 40 } # 2r - - { $ref: templates/claude_team_usage.yaml#/templates/bob, metric_date: "2026-12-10", unique_key: cc-bob-20261210, total_lines_accepted: 20 } # 1r - # ── carol, r=30 ───────────────────────────────────────────────────────────── - - { $ref: templates/claude_team_usage.yaml#/templates/carol, metric_date: "2026-11-15", unique_key: cc-carol-20261115, total_lines_accepted: 60 } # 2r - - { $ref: templates/claude_team_usage.yaml#/templates/carol, metric_date: "2026-12-10", unique_key: cc-carol-20261210, total_lines_accepted: 30 } # 1r - # ── dave, r=40 ────────────────────────────────────────────────────────────── - - { $ref: templates/claude_team_usage.yaml#/templates/claude_team_metrics, email: dave@example.com, metric_date: "2026-11-15", unique_key: cc-dave-20261115, total_lines_accepted: 80 } # 2r - - { $ref: templates/claude_team_usage.yaml#/templates/claude_team_metrics, email: dave@example.com, metric_date: "2026-12-10", unique_key: cc-dave-20261210, total_lines_accepted: 40 } # 1r - # ── erin, r=50 (the requested IC member) ──────────────────────────────────── - - { $ref: templates/claude_team_usage.yaml#/templates/claude_team_metrics, email: erin@example.com, metric_date: "2026-11-15", unique_key: cc-erin-20261115, total_lines_accepted: 100 } # 2r - - { $ref: templates/claude_team_usage.yaml#/templates/claude_team_metrics, email: erin@example.com, metric_date: "2026-12-10", unique_key: cc-erin-20261210, total_lines_accepted: 50 } # 1r - -cases: - # ── CUSTOM scope (Nov 01 .. Dec 12) — Nov 15 + Dec 10 rows ─────────────────── - # per-person sums 3r → dept {30,60,90,120,150}; erin value 150. - - name: "cc_lines — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-11-01' and metric_date le '2026-12-12'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: cc_lines } - equal: { value: 150, median: 90, range_min: 30, range_max: 150 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "cc_lines — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/ai_cc_overage.test.yaml b/src/ingestion/tests/e2e/metrics/ai_cc_overage.test.yaml deleted file mode 100644 index 0bbfc5536..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_cc_overage.test.yaml +++ /dev/null @@ -1,97 +0,0 @@ -spec_version: 1 -description: > - Metric: cc_overage — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: Claude Team per-seat spend snapshot (used credits + monthly limit, in cents) - • silver: class_ai_overage — LATEST snapshot per (seat, calendar MONTH); - overage_cents = max(0, used_credits − monthly_credit_limit). The snapshot - has no date field — period_month / collected_at derive from - _airbyte_extracted_at, and the silver model dedups - `LIMIT 1 BY tenant, source, account_uuid, toStartOfMonth(_airbyte_extracted_at)`. - • gold: ai_bullet_rows branch 6 emits ('cc_overage', overage_cents) dated at - toDate(collected_at). The served IC query SUMs the requested person's - per-month overage over the window (value), and reports the DEPARTMENT - distribution of those per-person SUMs as median/range_min/range_max. - - Stat set: value/median/range_min/range_max only (NO p25/p75/n) — the IC Bullet AI - query_ref is last re-seeded by m20260618_000001, which carries those four. The - no-unasserted-stat gate is self-scoping, so asserting these four suffices. - - Overage is a MONTHLY-grain metric (one snapshot per seat per calendar month), so - each ring is a DISTINCT month. Each member is seeded two monthly snapshots inside - the custom window (Nov 01 .. Dec 12): Nov (Nov 15) + Dec (Dec 05) — each overriding - _airbyte_extracted_at AND collected_at so the snapshot lands in its month, with - used_credits = limit + r so each month contributes overage = r cents (limit=100). - Per-person window SUM = 2*r (two months). The IC case requests erin (r=100) → value - 200; the department of 5 yields the median/range below. account_uuid (seat) MUST be - distinct per person so the per-(seat, month) dedup keeps each member's snapshot - separate. Cases: custom scope · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_claude_team.claude_team_overage_spend: - # monthly_credit_limit = 100¢ on every row; used_credits = 100 + r → overage = r. - # Nov (Nov 15) + Dec (Dec 05) snapshots → window SUM = 2*r. - # ── alice, r=20¢ (seat-alice) ─────────────────────────────────────────────── - - { $ref: templates/claude_team_overage.yaml#/templates/alice, unique_key: overage-alice-202611, _airbyte_extracted_at: "2026-11-15T00:00:00Z", collected_at: "2026-11-15T00:00:00Z", monthly_credit_limit: 100, used_credits: 120 } # Nov - - { $ref: templates/claude_team_overage.yaml#/templates/alice, unique_key: overage-alice-202612, _airbyte_extracted_at: "2026-12-05T00:00:00Z", collected_at: "2026-12-05T00:00:00Z", monthly_credit_limit: 100, used_credits: 120 } # Dec - - { $ref: templates/claude_team_overage.yaml#/templates/alice, unique_key: overage-alice-202612, _airbyte_extracted_at: "2026-12-05T00:00:00Z", collected_at: "2026-12-05T00:00:00Z", monthly_credit_limit: 100, used_credits: 120 } # re-sync dup (same seat+month) → kept once - # ── bob, r=40¢ (seat-bob) ─────────────────────────────────────────────────── - - { $ref: templates/claude_team_overage.yaml#/templates/bob, unique_key: overage-bob-202611, _airbyte_extracted_at: "2026-11-15T00:00:00Z", collected_at: "2026-11-15T00:00:00Z", monthly_credit_limit: 100, used_credits: 140 } # Nov - - { $ref: templates/claude_team_overage.yaml#/templates/bob, unique_key: overage-bob-202612, _airbyte_extracted_at: "2026-12-05T00:00:00Z", collected_at: "2026-12-05T00:00:00Z", monthly_credit_limit: 100, used_credits: 140 } # Dec - # ── carol, r=60¢ (seat-carol) ─────────────────────────────────────────────── - - { $ref: templates/claude_team_overage.yaml#/templates/carol, unique_key: overage-carol-202611, _airbyte_extracted_at: "2026-11-15T00:00:00Z", collected_at: "2026-11-15T00:00:00Z", monthly_credit_limit: 100, used_credits: 160 } # Nov - - { $ref: templates/claude_team_overage.yaml#/templates/carol, unique_key: overage-carol-202612, _airbyte_extracted_at: "2026-12-05T00:00:00Z", collected_at: "2026-12-05T00:00:00Z", monthly_credit_limit: 100, used_credits: 160 } # Dec - # ── dave, r=80¢ (seat-dave) ───────────────────────────────────────────────── - - { $ref: templates/claude_team_overage.yaml#/templates/overage_seat, account_email: dave@example.com, account_uuid: seat-dave, unique_key: overage-dave-202611, _airbyte_extracted_at: "2026-11-15T00:00:00Z", collected_at: "2026-11-15T00:00:00Z", monthly_credit_limit: 100, used_credits: 180 } # Nov - - { $ref: templates/claude_team_overage.yaml#/templates/overage_seat, account_email: dave@example.com, account_uuid: seat-dave, unique_key: overage-dave-202612, _airbyte_extracted_at: "2026-12-05T00:00:00Z", collected_at: "2026-12-05T00:00:00Z", monthly_credit_limit: 100, used_credits: 180 } # Dec - # ── erin, r=100¢ (seat-erin; the requested IC member) ─────────────────────── - - { $ref: templates/claude_team_overage.yaml#/templates/overage_seat, account_email: erin@example.com, account_uuid: seat-erin, unique_key: overage-erin-202611, _airbyte_extracted_at: "2026-11-15T00:00:00Z", collected_at: "2026-11-15T00:00:00Z", monthly_credit_limit: 100, used_credits: 200 } # Nov - - { $ref: templates/claude_team_overage.yaml#/templates/overage_seat, account_email: erin@example.com, account_uuid: seat-erin, unique_key: overage-erin-202612, _airbyte_extracted_at: "2026-12-05T00:00:00Z", collected_at: "2026-12-05T00:00:00Z", monthly_credit_limit: 100, used_credits: 200 } # Dec - -cases: - # ── CUSTOM scope (Nov 01 .. Dec 12) — Nov + Dec snapshots ──────────────────── - # per-person sums 2r → dept {40,80,120,160,200}; erin value 200. - - name: "cc_overage — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-11-01' and metric_date le '2026-12-12'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: cc_overage } - equal: { value: 200, median: 120, range_min: 40, range_max: 200 } - - # ── EMPTY window (Jun — between the Nov/Dec snapshots and any other → no items) - - name: "cc_overage — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/ai_cc_sessions.test.yaml b/src/ingestion/tests/e2e/metrics/ai_cc_sessions.test.yaml deleted file mode 100644 index 91cba5911..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_cc_sessions.test.yaml +++ /dev/null @@ -1,92 +0,0 @@ -spec_version: 1 -description: > - Metric: cc_sessions — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: Claude Team daily per-user metrics (session count) - • silver: class_ai_dev_usage, deduped to one row per user/day (tool='claude_code') - • gold: ai_bullet_rows branch 3 emits ('cc_sessions', session_count) per day; - the served IC query SUMs the requested person's per-day sessions over - the window (value), and reports the DEPARTMENT distribution of those - per-person SUMs as median/range_min/range_max. - - NOTE on the served stat set: the IC Bullet AI query_ref (id …0013) is last - re-seeded by migration m20260618_000001 (cc_overage), which carries only - value/median/range_min/range_max — NOT p25/p75/n. (The m20260611 reconcile - added p25/p75/n, but m20260618 runs after it and reverts to the median/min/max - shape.) So these AI fixtures assert the four stats the API actually returns; the - no-unasserted-stat gate is self-scoping and never requires p25/p75 on a row that - does not carry them. - - Department: 5 Engineering members (alice, bob, carol, dave, erin), base rates - r ∈ {3,6,9,12,15}. Each member is seeded two session-rows inside the custom window - (Nov 01 .. Dec 12) — one Nov 15 (2*r) + one Dec 10 (1*r) — so each per-person - window SUM = 3*r. The IC case requests erin (r=15) → value 45; the department of 5 - yields the median/range below. Cases: custom scope · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_claude_team.claude_team_code_metrics: - # Per member: Nov 15 = 2*r, Dec 10 = 1*r → window SUM = 3*r. - # ── alice, r=3 ────────────────────────────────────────────────────────────── - - { $ref: templates/claude_team_usage.yaml#/templates/alice, metric_date: "2026-11-15", unique_key: cc-alice-20261115, total_sessions: 6 } # 2r - - { $ref: templates/claude_team_usage.yaml#/templates/alice, metric_date: "2026-12-10", unique_key: cc-alice-20261210, total_sessions: 3 } # 1r - - { $ref: templates/claude_team_usage.yaml#/templates/alice, metric_date: "2026-12-10", unique_key: cc-alice-20261210, total_sessions: 3 } # duplicate → dedup to 3, not 6 - # ── bob, r=6 ──────────────────────────────────────────────────────────────── - - { $ref: templates/claude_team_usage.yaml#/templates/bob, metric_date: "2026-11-15", unique_key: cc-bob-20261115, total_sessions: 12 } # 2r - - { $ref: templates/claude_team_usage.yaml#/templates/bob, metric_date: "2026-12-10", unique_key: cc-bob-20261210, total_sessions: 6 } # 1r - # ── carol, r=9 ────────────────────────────────────────────────────────────── - - { $ref: templates/claude_team_usage.yaml#/templates/carol, metric_date: "2026-11-15", unique_key: cc-carol-20261115, total_sessions: 18 } # 2r - - { $ref: templates/claude_team_usage.yaml#/templates/carol, metric_date: "2026-12-10", unique_key: cc-carol-20261210, total_sessions: 9 } # 1r - # ── dave, r=12 ────────────────────────────────────────────────────────────── - - { $ref: templates/claude_team_usage.yaml#/templates/claude_team_metrics, email: dave@example.com, metric_date: "2026-11-15", unique_key: cc-dave-20261115, total_sessions: 24 } # 2r - - { $ref: templates/claude_team_usage.yaml#/templates/claude_team_metrics, email: dave@example.com, metric_date: "2026-12-10", unique_key: cc-dave-20261210, total_sessions: 12 } # 1r - # ── erin, r=15 (the requested IC member) ──────────────────────────────────── - - { $ref: templates/claude_team_usage.yaml#/templates/claude_team_metrics, email: erin@example.com, metric_date: "2026-11-15", unique_key: cc-erin-20261115, total_sessions: 30 } # 2r - - { $ref: templates/claude_team_usage.yaml#/templates/claude_team_metrics, email: erin@example.com, metric_date: "2026-12-10", unique_key: cc-erin-20261210, total_sessions: 15 } # 1r - -cases: - # ── CUSTOM scope (Nov 01 .. Dec 12) — Nov 15 + Dec 10 rows ─────────────────── - # per-person sums 3r → dept {9,18,27,36,45}; erin value 45. - - name: "cc_sessions — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-11-01' and metric_date le '2026-12-12'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: cc_sessions } - equal: { value: 45, median: 27, range_min: 9, range_max: 45 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "cc_sessions — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/ai_cc_tool_accept.test.yaml b/src/ingestion/tests/e2e/metrics/ai_cc_tool_accept.test.yaml deleted file mode 100644 index aa31a4477..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_cc_tool_accept.test.yaml +++ /dev/null @@ -1,96 +0,0 @@ -spec_version: 1 -description: > - Metric: cc_tool_accept — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: Claude Enterprise per-user/day usage (code tool suggestions accepted) - • silver: class_ai_dev_usage (tool='claude_code'); one row per user/day - (LIMIT 1 BY tenant, source, user_id, date); tool_use_accepted ← - code_tool_accepted_count - • gold: ai_bullet_rows branch 3 emits ('cc_tool_accept', tool_use_accepted) per - day; the served IC query SUMs the requested person's per-day accepted - suggestions over the window (value), and reports the DEPARTMENT - distribution of those per-person SUMs as median/range_min/range_max. - - NOTE on the served stat set: the IC Bullet AI query_ref (id …0013) is last - re-seeded by migration m20260618_000001 (cc_overage), which carries only - value/median/range_min/range_max — NOT p25/p75/n. So these AI fixtures assert the - four stats the API actually returns; the no-unasserted-stat gate is self-scoping - and never requires p25/p75 on a row that does not carry them. - - user_id MUST be distinct per person — the silver model dedups - `LIMIT 1 BY tenant, source, user_id, date`, so a shared user_id would collapse - all five seats into one row (cohort of 1 → wrong department median). - - Department: 5 Engineering members (alice, bob, carol, dave, erin), base rates - r ∈ {5,10,15,20,25}. Each member is seeded two accepted-rows inside the custom - window (Nov 01 .. Dec 12) — one Nov 15 (2*r) + one Dec 10 (1*r) — so each - per-person window SUM = 3*r. The IC case requests erin (r=25) → value 75; the - department of 5 yields the median/range below. Cases: custom scope · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_claude_enterprise.claude_enterprise_users: - # Per member: Nov 15 = 2*r, Dec 10 = 1*r → window SUM = 3*r. - # ── alice, r=5 (user-alice) ───────────────────────────────────────────────── - - { $ref: templates/claude_enterprise_users.yaml#/templates/alice, date: "2026-11-15", unique_key: ent-alice-20261115, code_tool_accepted_count: 10 } # 2r - - { $ref: templates/claude_enterprise_users.yaml#/templates/alice, date: "2026-12-10", unique_key: ent-alice-20261210, code_tool_accepted_count: 5 } # 1r - - { $ref: templates/claude_enterprise_users.yaml#/templates/alice, date: "2026-12-10", unique_key: ent-alice-20261210, code_tool_accepted_count: 5 } # re-sync dup (same user_id+date) → dedup to 5, not 10 - # ── bob, r=10 (user-bob) ──────────────────────────────────────────────────── - - { $ref: templates/claude_enterprise_users.yaml#/templates/bob, date: "2026-11-15", unique_key: ent-bob-20261115, code_tool_accepted_count: 20 } # 2r - - { $ref: templates/claude_enterprise_users.yaml#/templates/bob, date: "2026-12-10", unique_key: ent-bob-20261210, code_tool_accepted_count: 10 } # 1r - # ── carol, r=15 (user-carol) ──────────────────────────────────────────────── - - { $ref: templates/claude_enterprise_users.yaml#/templates/carol, date: "2026-11-15", unique_key: ent-carol-20261115, code_tool_accepted_count: 30 } # 2r - - { $ref: templates/claude_enterprise_users.yaml#/templates/carol, date: "2026-12-10", unique_key: ent-carol-20261210, code_tool_accepted_count: 15 } # 1r - # ── dave, r=20 (user-dave) ────────────────────────────────────────────────── - - { $ref: templates/claude_enterprise_users.yaml#/templates/ent_user, user_email: dave@example.com, user_id: user-dave, date: "2026-11-15", unique_key: ent-dave-20261115, code_tool_accepted_count: 40 } # 2r - - { $ref: templates/claude_enterprise_users.yaml#/templates/ent_user, user_email: dave@example.com, user_id: user-dave, date: "2026-12-10", unique_key: ent-dave-20261210, code_tool_accepted_count: 20 } # 1r - # ── erin, r=25 (user-erin; the requested IC member) ───────────────────────── - - { $ref: templates/claude_enterprise_users.yaml#/templates/ent_user, user_email: erin@example.com, user_id: user-erin, date: "2026-11-15", unique_key: ent-erin-20261115, code_tool_accepted_count: 50 } # 2r - - { $ref: templates/claude_enterprise_users.yaml#/templates/ent_user, user_email: erin@example.com, user_id: user-erin, date: "2026-12-10", unique_key: ent-erin-20261210, code_tool_accepted_count: 25 } # 1r - -cases: - # ── CUSTOM scope (Nov 01 .. Dec 12) — Nov 15 + Dec 10 rows ─────────────────── - # per-person sums 3r → dept {15,30,45,60,75}; erin value 75. - - name: "cc_tool_accept — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-11-01' and metric_date le '2026-12-12'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: cc_tool_accept } - equal: { value: 75, median: 45, range_min: 15, range_max: 75 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "cc_tool_accept — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/ai_chatgpt_active.test.yaml b/src/ingestion/tests/e2e/metrics/ai_chatgpt_active.test.yaml deleted file mode 100644 index ac44c9611..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_chatgpt_active.test.yaml +++ /dev/null @@ -1,55 +0,0 @@ -spec_version: 1 -description: > - Metric: chatgpt_active — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: ChatGPT daily per-user chat activity — one row marks a chat user - • silver: deduped to one row per user/day - • gold: an "active" metric — value=1 for the requested person; range_max = - count of active ChatGPT chat members in the team (median is 0 by rule) - - Team (median/range = the person's department): - alice · bob · carol all active → value 1, median 0, range [0, 3]. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - bronze_chatgpt_team.chatgpt_team_chat_activity: - # Any active chat row counts; the model emits a row when a counter > 0. - - $ref: templates/chatgpt_team_chat.yaml#/templates/alice - unique_key: chat-alice-20260105 - date: "2026-01-05" - messages: 5 - - $ref: templates/chatgpt_team_chat.yaml#/templates/alice # re-sync dup → must dedup (active stays 1, not 2) - unique_key: chat-alice-20260105 - date: "2026-01-05" - messages: 5 - - $ref: templates/chatgpt_team_chat.yaml#/templates/bob - unique_key: chat-bob-20260105 - date: "2026-01-05" - messages: 5 - - $ref: templates/chatgpt_team_chat.yaml#/templates/carol - unique_key: chat-carol-20260105 - date: "2026-01-05" - messages: 5 - -cases: - - name: chatgpt_active — IC bullet (1 of 3 active members) - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: chatgpt_active } - equal: { value: 1, median: 0, range_min: 0, range_max: 3 } diff --git a/src/ingestion/tests/e2e/metrics/ai_claude_web.test.yaml b/src/ingestion/tests/e2e/metrics/ai_claude_web.test.yaml deleted file mode 100644 index e40a650dd..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_claude_web.test.yaml +++ /dev/null @@ -1,39 +0,0 @@ -spec_version: 1 -description: > - Metric: claude_web — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: none — Claude.ai web activity is not yet ingested - • silver: none - • gold: locked "coming soon" stub — served as a hardcoded NULL regardless of - seeded data - - Cases: one Cursor row so the person appears in the bullet; the stub stays NULL. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - bronze_cursor.cursor_daily_usage: - # one cursor row so alice appears in ai_bullet_rows (the stub is null regardless). - - $ref: templates/cursor_usage.yaml#/templates/alice - unique_key: cursor-alice-20260105 - date: 1767571200000 - -cases: - - name: claude_web — locked stub returns NULL - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: claude_web } - equal: { value: null, median: null, range_min: null, range_max: null } diff --git a/src/ingestion/tests/e2e/metrics/ai_codex_active.test.yaml b/src/ingestion/tests/e2e/metrics/ai_codex_active.test.yaml deleted file mode 100644 index 49d9b42b0..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_codex_active.test.yaml +++ /dev/null @@ -1,55 +0,0 @@ -spec_version: 1 -description: > - Metric: codex_active — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: Codex daily per-user leaderboard — one row marks a Codex user - • silver: deduped to one row per user/day - • gold: an "active" metric — value=1 for the requested person; range_max = - count of active Codex members in the team (median is 0 by rule) - - Team (median/range = the person's department): - alice · bob · carol all active → value 1, median 0, range [0, 3]. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - bronze_chatgpt_team.chatgpt_team_codex_user_daily: - # Any active codex row counts; the model emits a row when a counter > 0. - - $ref: templates/chatgpt_team_codex.yaml#/templates/alice - unique_key: codex-alice-20260105 - date: "2026-01-05" - lines_added: 5 - - $ref: templates/chatgpt_team_codex.yaml#/templates/alice # re-sync dup → must dedup (active stays 1, not 2) - unique_key: codex-alice-20260105 - date: "2026-01-05" - lines_added: 5 - - $ref: templates/chatgpt_team_codex.yaml#/templates/bob - unique_key: codex-bob-20260105 - date: "2026-01-05" - lines_added: 5 - - $ref: templates/chatgpt_team_codex.yaml#/templates/carol - unique_key: codex-carol-20260105 - date: "2026-01-05" - lines_added: 5 - -cases: - - name: codex_active — IC bullet (1 of 3 active members) - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: codex_active } - equal: { value: 1, median: 0, range_min: 0, range_max: 3 } diff --git a/src/ingestion/tests/e2e/metrics/ai_codex_lines.test.yaml b/src/ingestion/tests/e2e/metrics/ai_codex_lines.test.yaml deleted file mode 100644 index 634efcc14..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_codex_lines.test.yaml +++ /dev/null @@ -1,54 +0,0 @@ -spec_version: 1 -description: > - Metric: codex_lines — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: Codex daily per-user leaderboard (accepted lines, threads, credits) - • silver: deduped to one row per user/day carrying the AI-accepted-lines count - • gold: metric = sum of Codex AI-accepted lines over the window - - Team (median/range = the person's department): - alice 40 · bob 20 · carol 10 (alice row re-synced once → must not double) - → median 20, range [10, 40]. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - bronze_chatgpt_team.chatgpt_team_codex_user_daily: - - $ref: templates/chatgpt_team_codex.yaml#/templates/alice - unique_key: codex-alice-20260105 - date: "2026-01-05" - lines_added: 40 - - $ref: templates/chatgpt_team_codex.yaml#/templates/alice # re-sync dup → must NOT double - unique_key: codex-alice-20260105 - date: "2026-01-05" - lines_added: 40 - - $ref: templates/chatgpt_team_codex.yaml#/templates/bob - unique_key: codex-bob-20260105 - date: "2026-01-05" - lines_added: 20 - - $ref: templates/chatgpt_team_codex.yaml#/templates/carol - unique_key: codex-carol-20260105 - date: "2026-01-05" - lines_added: 10 - -cases: - - name: codex_lines — IC bullet (alice value vs team median, dedup holds) - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: codex_lines } - equal: { value: 40, median: 20, range_min: 10, range_max: 40 } diff --git a/src/ingestion/tests/e2e/metrics/ai_cc_cost.test.yaml b/src/ingestion/tests/e2e/metrics/ai_cost.test.yaml similarity index 76% rename from src/ingestion/tests/e2e/metrics/ai_cc_cost.test.yaml rename to src/ingestion/tests/e2e/metrics/ai_cost.test.yaml index 7b4124bed..37a196b0c 100644 --- a/src/ingestion/tests/e2e/metrics/ai_cc_cost.test.yaml +++ b/src/ingestion/tests/e2e/metrics/ai_cost.test.yaml @@ -1,6 +1,6 @@ spec_version: 1 description: > - Metric: cc_cost — IC Bullet AI (…0013), #1440. + Metric: ai.cost. How it's computed (bronze → silver → gold): • bronze: Claude Team daily per-user metrics (cost as a decimal string, e.g. "2.00") • silver: class_ai_dev_usage, deduped to one row per user/day (tool='claude_code'); @@ -53,40 +53,52 @@ bronze: cases: # ── CUSTOM scope (Nov 01 .. Dec 12) — Nov 15 + Dec 10 rows ─────────────────── # per-person sums 3r → dept {60,120,180,240,300}; erin value 300. - - name: "cc_cost — custom scope (department of 5)" + - name: AI cost request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-11-01' and metric_date le '2026-12-12'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-11-01", to: "2026-12-12" } + metrics: + - metric_key: ai.cost + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } expect: - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: cc_cost } - equal: { value: 300, median: 180, range_min: 60, range_max: 300 } + - metric: ai.cost + view: period + find: { entity_id: erin@example.com } + equal: { value: 3 } + - metric: ai.cost + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 3, p25: 1.2, median: 1.8, p75: 2.4, min: 0.6, max: 3, n: 5 } + - metric: ai.cost + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-11-15' && double(p.value) == 2.0) && s.points.exists(p, p.bucket_start == '2026-12-10' && double(p.value) == 1.0))" + - metric: ai.cost + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool' && d.value == 'claude_code') && double(v.value) == 3.0)" # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "cc_cost — EMPTY window (no data → no items)" + - name: AI cost empty window request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-06-01", to: "2026-06-30" } + metrics: + - metric_key: ai.cost + views: + - { view: period } expect: - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - assert: "size(items) == 0" + - metric: ai.cost + view: period + find: { entity_id: erin@example.com } + equal: { value: 0 } diff --git a/src/ingestion/tests/e2e/metrics/ai_cursor_acceptance.test.yaml b/src/ingestion/tests/e2e/metrics/ai_cursor_acceptance.test.yaml deleted file mode 100644 index 649514e5b..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_cursor_acceptance.test.yaml +++ /dev/null @@ -1,59 +0,0 @@ -spec_version: 1 -description: > - Metric: cursor_acceptance — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: Cursor daily per-developer usage report (tab completions shown & accepted) - • silver: deduped to one row per developer/day carrying shown + accepted counts - • gold: a reconstructed ratio — 100 × Σ accepted ÷ Σ shown, rounded to 0.1 - (computed at serve time, not stored as a row) - - Team (value = the requested person): - alice accepted 50 of 80 shown → 100×50/80 = 62.5. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - bronze_cursor.cursor_daily_usage: - # date 1767571200000 ms = 2026-01-05. - - $ref: templates/cursor_usage.yaml#/templates/alice - unique_key: cursor-alice-20260105 - date: 1767571200000 - totalTabsAccepted: 50 - totalTabsShown: 80 - - $ref: templates/cursor_usage.yaml#/templates/alice # re-sync dup → ratio unchanged - unique_key: cursor-alice-20260105 - date: 1767571200000 - totalTabsAccepted: 50 - totalTabsShown: 80 - - $ref: templates/cursor_usage.yaml#/templates/bob - unique_key: cursor-bob-20260105 - date: 1767571200000 - totalTabsAccepted: 20 - totalTabsShown: 80 - - $ref: templates/cursor_usage.yaml#/templates/carol - unique_key: cursor-carol-20260105 - date: 1767571200000 - totalTabsAccepted: 10 - totalTabsShown: 25 - -cases: - - name: cursor_acceptance — IC bullet (reconstructed ratio, alice 50/80 = 62.5) - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: cursor_acceptance } - equal: { value: 62.5, median: 40.0, range_min: 25.0, range_max: 62.5 } diff --git a/src/ingestion/tests/e2e/metrics/ai_cursor_agents.test.yaml b/src/ingestion/tests/e2e/metrics/ai_cursor_agents.test.yaml deleted file mode 100644 index aff209829..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_cursor_agents.test.yaml +++ /dev/null @@ -1,54 +0,0 @@ -spec_version: 1 -description: > - Metric: cursor_agents — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: Cursor daily per-developer usage report (agent requests) - • silver: deduped to one row per developer/day carrying the agent-request count - • gold: metric = sum of Cursor agent requests over the window - - Team (median/range = the person's department): - alice 30 · bob 10 · carol 5 → median 10, range [5, 30]. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - bronze_cursor.cursor_daily_usage: - # date 1767571200000 ms = 2026-01-05. - - $ref: templates/cursor_usage.yaml#/templates/alice - unique_key: cursor-alice-20260105 - date: 1767571200000 - agentRequests: 30 - - $ref: templates/cursor_usage.yaml#/templates/alice # re-sync dup → must NOT double - unique_key: cursor-alice-20260105 - date: 1767571200000 - agentRequests: 30 - - $ref: templates/cursor_usage.yaml#/templates/bob - unique_key: cursor-bob-20260105 - date: 1767571200000 - agentRequests: 10 - - $ref: templates/cursor_usage.yaml#/templates/carol - unique_key: cursor-carol-20260105 - date: 1767571200000 - agentRequests: 5 - -cases: - - name: cursor_agents — IC bullet (alice value vs team median, dedup holds) - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: cursor_agents } - equal: { value: 30, median: 10, range_min: 5, range_max: 30 } diff --git a/src/ingestion/tests/e2e/metrics/ai_cursor_completions.test.yaml b/src/ingestion/tests/e2e/metrics/ai_cursor_completions.test.yaml deleted file mode 100644 index 963711f62..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_cursor_completions.test.yaml +++ /dev/null @@ -1,54 +0,0 @@ -spec_version: 1 -description: > - Metric: cursor_completions — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: Cursor daily per-developer usage report (tab completions accepted) - • silver: deduped to one row per developer/day carrying the accepted-completions count - • gold: metric = sum of accepted tab-completions over the window - - Team (median/range = the person's department): - alice 50 · bob 20 · carol 10 → median 20, range [10, 50]. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - bronze_cursor.cursor_daily_usage: - # date 1767571200000 ms = 2026-01-05. - - $ref: templates/cursor_usage.yaml#/templates/alice - unique_key: cursor-alice-20260105 - date: 1767571200000 - totalTabsAccepted: 50 - - $ref: templates/cursor_usage.yaml#/templates/alice # re-sync dup → must NOT double - unique_key: cursor-alice-20260105 - date: 1767571200000 - totalTabsAccepted: 50 - - $ref: templates/cursor_usage.yaml#/templates/bob - unique_key: cursor-bob-20260105 - date: 1767571200000 - totalTabsAccepted: 20 - - $ref: templates/cursor_usage.yaml#/templates/carol - unique_key: cursor-carol-20260105 - date: 1767571200000 - totalTabsAccepted: 10 - -cases: - - name: cursor_completions — IC bullet (alice value vs team median, dedup holds) - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: cursor_completions } - equal: { value: 50, median: 20, range_min: 10, range_max: 50 } diff --git a/src/ingestion/tests/e2e/metrics/ai_cursor_lines.test.yaml b/src/ingestion/tests/e2e/metrics/ai_cursor_lines.test.yaml deleted file mode 100644 index cafd66099..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_cursor_lines.test.yaml +++ /dev/null @@ -1,55 +0,0 @@ -spec_version: 1 -description: > - Metric: cursor_lines — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: Cursor daily per-developer usage report (accepted lines, completions, agents) - • silver: deduped to one row per developer/day carrying the accepted-lines count - • gold: metric = sum of AI-accepted lines over the window - - Team (median/range = the person's department): - alice 40 · bob 20 · carol 10 (alice row re-synced once → must not double) - → median 20, range [10, 40]. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - bronze_cursor.cursor_daily_usage: - # date 1767571200000 ms = 2026-01-05. - - $ref: templates/cursor_usage.yaml#/templates/alice - unique_key: cursor-alice-20260105 - date: 1767571200000 - acceptedLinesAdded: 40 - - $ref: templates/cursor_usage.yaml#/templates/alice # re-sync dup → must NOT double - unique_key: cursor-alice-20260105 - date: 1767571200000 - acceptedLinesAdded: 40 - - $ref: templates/cursor_usage.yaml#/templates/bob - unique_key: cursor-bob-20260105 - date: 1767571200000 - acceptedLinesAdded: 20 - - $ref: templates/cursor_usage.yaml#/templates/carol - unique_key: cursor-carol-20260105 - date: 1767571200000 - acceptedLinesAdded: 10 - -cases: - - name: cursor_lines — IC bullet (alice value vs team median, dedup holds) - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: cursor_lines } - equal: { value: 40, median: 20, range_min: 10, range_max: 40 } diff --git a/src/ingestion/tests/e2e/metrics/ai_codex_sessions.test.yaml b/src/ingestion/tests/e2e/metrics/ai_dev_conversations.test.yaml similarity index 52% rename from src/ingestion/tests/e2e/metrics/ai_codex_sessions.test.yaml rename to src/ingestion/tests/e2e/metrics/ai_dev_conversations.test.yaml index 92b6c0bfa..a0b468933 100644 --- a/src/ingestion/tests/e2e/metrics/ai_codex_sessions.test.yaml +++ b/src/ingestion/tests/e2e/metrics/ai_dev_conversations.test.yaml @@ -1,6 +1,6 @@ spec_version: 1 description: > - Metric: codex_sessions — IC Bullet AI (…0013), #1440. + Metric: ai.dev_conversations. How it's computed (bronze → silver → gold): • bronze: Codex daily per-user leaderboard (thread count ≈ coding sessions) • silver: deduped to one row per user/day carrying the session count @@ -33,21 +33,33 @@ bronze: n_threads: 3 cases: - - name: codex_sessions — IC bullet (alice value vs team median, dedup holds) + - name: AI development conversations request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key + entity: { type: person, ids: [alice@example.com] } + period: { from: "2026-01-01", to: "2026-01-31" } + metrics: + - metric_key: ai.dev_conversations + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } expect: - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: codex_sessions } - equal: { value: 12, median: 6, range_min: 3, range_max: 12 } + - metric: ai.dev_conversations + view: period + find: { entity_id: alice@example.com } + equal: { value: 12 } + - metric: ai.dev_conversations + view: peer + find: { entity_id: alice@example.com } + equal: { target_value: 12, p25: null, median: null, p75: null, min: null, max: null, n: 3 } + - metric: ai.dev_conversations + view: timeseries + assert: "items.exists(s, s.entity_id == 'alice@example.com' && s.points.exists(p, p.bucket_start == '2026-01-05' && double(p.value) == 12.0))" + - metric: ai.dev_conversations + view: breakdown + assert: "items.exists(v, v.entity_id == 'alice@example.com' && v.dimensions.exists(d, d.key == 'tool' && d.value == 'codex') && double(v.value) == 12.0)" diff --git a/src/ingestion/tests/e2e/metrics/ai_cc_tool_acceptance.test.yaml b/src/ingestion/tests/e2e/metrics/ai_edit_acceptance.test.yaml similarity index 68% rename from src/ingestion/tests/e2e/metrics/ai_cc_tool_acceptance.test.yaml rename to src/ingestion/tests/e2e/metrics/ai_edit_acceptance.test.yaml index d2957549e..125e86c32 100644 --- a/src/ingestion/tests/e2e/metrics/ai_cc_tool_acceptance.test.yaml +++ b/src/ingestion/tests/e2e/metrics/ai_edit_acceptance.test.yaml @@ -1,6 +1,6 @@ spec_version: 1 description: > - Metric: cc_tool_acceptance — IC Bullet AI (…0013), #1440. + Metrics: ai.accepted_edit_actions and ai.tool_acceptance_rate. How it's computed (bronze → silver → gold): • bronze: Claude Enterprise per-user/day usage (code tool suggestions accepted & rejected) • silver: class_ai_dev_usage (tool='claude_code'); one row per user/day; tool_use_accepted @@ -60,40 +60,72 @@ bronze: cases: # ── CUSTOM scope (Nov 01 .. Dec 12) — Nov 15 + Dec 10 rows ─────────────────── # per-person ratio = b+15 → dept {20,25,30,35,40}; erin value 40. - - name: "cc_tool_acceptance — custom scope (department of 5)" + - name: AI accepted edits and acceptance rate request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-11-01' and metric_date le '2026-12-12'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-11-01", to: "2026-12-12" } + metrics: + - metric_key: ai.accepted_edit_actions + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } + - metric_key: ai.tool_acceptance_rate + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } expect: - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: cc_tool_acceptance } - equal: { value: 40, median: 30, range_min: 20, range_max: 40 } + - metric: ai.accepted_edit_actions + view: period + find: { entity_id: erin@example.com } + equal: { value: 80 } + - metric: ai.accepted_edit_actions + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 80, p25: 50, median: 60, p75: 70, min: 40, max: 80, n: 5 } + - metric: ai.accepted_edit_actions + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-11-15' && double(p.value) == 45.0) && s.points.exists(p, p.bucket_start == '2026-12-10' && double(p.value) == 35.0))" + - metric: ai.accepted_edit_actions + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool' && d.value == 'claude_code') && double(v.value) == 80.0)" + - metric: ai.tool_acceptance_rate + view: period + find: { entity_id: erin@example.com } + equal: { value: 40 } + - metric: ai.tool_acceptance_rate + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 40, p25: 25, median: 30, p75: 35, min: 20, max: 40, n: 5 } + - metric: ai.tool_acceptance_rate + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-11-15' && double(p.value) == 45.0) && s.points.exists(p, p.bucket_start == '2026-12-10' && double(p.value) == 35.0))" + - metric: ai.tool_acceptance_rate + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool' && d.value == 'claude_code') && double(v.value) == 40.0)" # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "cc_tool_acceptance — EMPTY window (no data → no items)" + - name: AI acceptance empty window request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-06-01", to: "2026-06-30" } + metrics: + - metric_key: ai.tool_acceptance_rate + views: + - { view: period } expect: - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - assert: "size(items) == 0" + - metric: ai.tool_acceptance_rate + view: period + find: { entity_id: erin@example.com } + equal: { value: null } diff --git a/src/ingestion/tests/e2e/metrics/ai_lines.test.yaml b/src/ingestion/tests/e2e/metrics/ai_lines.test.yaml new file mode 100644 index 000000000..178dc37f1 --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/ai_lines.test.yaml @@ -0,0 +1,91 @@ +spec_version: 1 +description: > + Metrics: ai.accepted_lines and ai.removed_lines. + How it's computed (bronze → silver → gold): + • bronze: Cursor daily per-developer usage report (accepted lines, completions, agents) + • silver: deduped to one row per developer/day carrying the accepted-lines count + • gold: metric = sum of AI-accepted lines over the window + + Team (median/range = the person's department): + alice 40 · bob 20 · carol 10 (alice row re-synced once → must not double) + → median 20, range [10, 40]. + +bronze: + bronze_bamboohr.employees: + - $ref: templates/people.yaml#/templates/alice + - $ref: templates/people.yaml#/templates/bob + - $ref: templates/people.yaml#/templates/carol + bronze_cursor.cursor_daily_usage: + # date 1767571200000 ms = 2026-01-05. + - $ref: templates/cursor_usage.yaml#/templates/alice + unique_key: cursor-alice-20260105 + date: 1767571200000 + acceptedLinesAdded: 40 + acceptedLinesDeleted: 8 + - $ref: templates/cursor_usage.yaml#/templates/alice # re-sync dup → must NOT double + unique_key: cursor-alice-20260105 + date: 1767571200000 + acceptedLinesAdded: 40 + acceptedLinesDeleted: 8 + - $ref: templates/cursor_usage.yaml#/templates/bob + unique_key: cursor-bob-20260105 + date: 1767571200000 + acceptedLinesAdded: 20 + acceptedLinesDeleted: 4 + - $ref: templates/cursor_usage.yaml#/templates/carol + unique_key: cursor-carol-20260105 + date: 1767571200000 + acceptedLinesAdded: 10 + acceptedLinesDeleted: 2 + +cases: + - name: AI accepted and removed lines + request: + url: /v1/metric-results + method: POST + body: + entity: { type: person, ids: [alice@example.com] } + period: { from: "2026-01-01", to: "2026-01-31" } + metrics: + - metric_key: ai.accepted_lines + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } + - metric_key: ai.removed_lines + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } + expect: + - assert: "status == 200" + - metric: ai.accepted_lines + view: period + find: { entity_id: alice@example.com } + equal: { value: 40 } + - metric: ai.accepted_lines + view: peer + find: { entity_id: alice@example.com } + equal: { target_value: 40, p25: null, median: null, p75: null, min: null, max: null, n: 3 } + - metric: ai.accepted_lines + view: timeseries + assert: "items.exists(s, s.entity_id == 'alice@example.com' && s.points.exists(p, p.bucket_start == '2026-01-05' && double(p.value) == 40.0))" + - metric: ai.accepted_lines + view: breakdown + assert: "items.exists(v, v.entity_id == 'alice@example.com' && v.dimensions.exists(d, d.key == 'tool' && d.value == 'cursor') && double(v.value) == 40.0)" + - metric: ai.removed_lines + view: period + find: { entity_id: alice@example.com } + equal: { value: 8 } + - metric: ai.removed_lines + view: peer + find: { entity_id: alice@example.com } + equal: { target_value: 8, p25: null, median: null, p75: null, min: null, max: null, n: 3 } + - metric: ai.removed_lines + view: timeseries + assert: "items.exists(s, s.entity_id == 'alice@example.com' && s.points.exists(p, p.bucket_start == '2026-01-05' && double(p.value) == 8.0))" + - metric: ai.removed_lines + view: breakdown + assert: "items.exists(v, v.entity_id == 'alice@example.com' && v.dimensions.exists(d, d.key == 'tool' && d.value == 'cursor') && double(v.value) == 8.0)" diff --git a/src/ingestion/tests/e2e/metrics/ai_prs_total.test.yaml b/src/ingestion/tests/e2e/metrics/ai_prs_total.test.yaml deleted file mode 100644 index 26c7e8641..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_prs_total.test.yaml +++ /dev/null @@ -1,39 +0,0 @@ -spec_version: 1 -description: > - Metric: prs_total — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: none — total-PRs attribution is not yet ingested - • silver: none - • gold: locked "coming soon" stub — served as a hardcoded NULL regardless of - seeded data - - Cases: one Cursor row so the person appears in the bullet; the stub stays NULL. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - bronze_cursor.cursor_daily_usage: - # one cursor row so alice appears in ai_bullet_rows (the stub is null regardless). - - $ref: templates/cursor_usage.yaml#/templates/alice - unique_key: cursor-alice-20260105 - date: 1767571200000 - -cases: - - name: prs_total — locked stub returns NULL - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: prs_total } - equal: { value: null, median: null, range_min: null, range_max: null } diff --git a/src/ingestion/tests/e2e/metrics/ai_prs_with_cc.test.yaml b/src/ingestion/tests/e2e/metrics/ai_prs_with_cc.test.yaml deleted file mode 100644 index 53207515f..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_prs_with_cc.test.yaml +++ /dev/null @@ -1,39 +0,0 @@ -spec_version: 1 -description: > - Metric: prs_with_cc — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: none — PRs-with-Claude-Code attribution is not yet ingested - • silver: none - • gold: locked "coming soon" stub — served as a hardcoded NULL regardless of - seeded data - - Cases: one Cursor row so the person appears in the bullet; the stub stays NULL. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - bronze_cursor.cursor_daily_usage: - # one cursor row so alice appears in ai_bullet_rows (the stub is null regardless). - - $ref: templates/cursor_usage.yaml#/templates/alice - unique_key: cursor-alice-20260105 - date: 1767571200000 - -cases: - - name: prs_with_cc — locked stub returns NULL - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: prs_with_cc } - equal: { value: null, median: null, range_min: null, range_max: null } diff --git a/src/ingestion/tests/e2e/metrics/ai_team_ai_loc.test.yaml b/src/ingestion/tests/e2e/metrics/ai_team_ai_loc.test.yaml deleted file mode 100644 index 0786d8e52..000000000 --- a/src/ingestion/tests/e2e/metrics/ai_team_ai_loc.test.yaml +++ /dev/null @@ -1,56 +0,0 @@ -spec_version: 1 -description: > - Metric: team_ai_loc — IC Bullet AI (…0013), #1440. - How it's computed (bronze → silver → gold): - • bronze: any AI-tool daily usage report (here Cursor only) - • silver: deduped to one row per person/day carrying each tool's accepted lines - • gold: tool-agnostic metric = sum of AI-accepted lines across all tools over - the window (Cursor-only seed here, so it mirrors cursor_lines; grows - once claude_team / chatgpt_team rows join) - - Team (median/range = the person's department): - alice 40 · bob 20 · carol 10 → median 20, range [10, 40]. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - bronze_cursor.cursor_daily_usage: - # date 1767571200000 ms = 2026-01-05. - - $ref: templates/cursor_usage.yaml#/templates/alice - unique_key: cursor-alice-20260105 - date: 1767571200000 - acceptedLinesAdded: 40 - - $ref: templates/cursor_usage.yaml#/templates/alice # re-sync dup → must NOT double - unique_key: cursor-alice-20260105 - date: 1767571200000 - acceptedLinesAdded: 40 - - $ref: templates/cursor_usage.yaml#/templates/bob - unique_key: cursor-bob-20260105 - date: 1767571200000 - acceptedLinesAdded: 20 - - $ref: templates/cursor_usage.yaml#/templates/carol - unique_key: cursor-carol-20260105 - date: 1767571200000 - acceptedLinesAdded: 10 - -cases: - - name: team_ai_loc — IC bullet (alice value vs team median, dedup holds) - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: ai - metric_id: 00000000-0000-0000-0001-000000000013 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-01-01' and metric_date le '2026-01-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: ai - assert: "result.status == 'ok'" - - in: ai - find: { metric_key: team_ai_loc } - equal: { value: 40, median: 20, range_min: 10, range_max: 40 } diff --git a/src/ingestion/tests/e2e/metrics/collab_active_days.test.yaml b/src/ingestion/tests/e2e/metrics/collab_activity.test.yaml similarity index 69% rename from src/ingestion/tests/e2e/metrics/collab_active_days.test.yaml rename to src/ingestion/tests/e2e/metrics/collab_activity.test.yaml index 9427de1a1..346aa5fea 100644 --- a/src/ingestion/tests/e2e/metrics/collab_active_days.test.yaml +++ b/src/ingestion/tests/e2e/metrics/collab_activity.test.yaml @@ -50,39 +50,49 @@ bronze: - { $ref: templates/m365_email.yaml#/templates/erin_email, reportRefreshDate: "2026-12-27", unique_key: ad-erin-20261227, sendCount: 1 } cases: - # ── CUSTOM scope (Dec 23 .. Dec 29) — department of 5 ──────────────────────── - # active-day counts → dept {1,2,3,4,5}; erin value 5. - # p25=2 median=3 p75=4 range=[1,5] - - name: "m365_active_days — custom scope (department of 5)" + - name: Unified collaboration activity request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-23' and metric_date le '2026-12-29'" + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-23", to: "2026-12-29" } + metrics: + - metric_key: collab.active_days + views: &activity_views + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } + - metric_key: collab.breadth + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } expect: - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: m365_active_days } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - # ── EMPTY window (no qualifying days in range) ─────────────────────────────── - - name: "m365_active_days — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" + - metric: collab.active_days + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: collab.active_days + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: collab.active_days + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 1.0))" + - metric: collab.active_days + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool' && d.value == 'm365') && double(v.value) == 5.0)" + - metric: collab.breadth + view: period + find: { entity_id: erin@example.com } + equal: { value: 1 } + - metric: collab.breadth + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 1, p25: 1, median: 1, p75: 1, min: 1, max: 1, n: 5 } + - metric: collab.breadth + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 1.0))" diff --git a/src/ingestion/tests/e2e/metrics/collab_emails_read.test.yaml b/src/ingestion/tests/e2e/metrics/collab_emails_read.test.yaml index 8ff706eb1..3017f44d3 100644 --- a/src/ingestion/tests/e2e/metrics/collab_emails_read.test.yaml +++ b/src/ingestion/tests/e2e/metrics/collab_emails_read.test.yaml @@ -31,39 +31,33 @@ bronze: - { $ref: templates/m365_email.yaml#/templates/erin_email, reportRefreshDate: "2026-12-25", unique_key: read-erin-20261225, readCount: 50 } # r=50 (the requested IC member) cases: - # ── CUSTOM scope (Dec 23 .. Dec 29) — department of 5 ──────────────────────── - # per-person SUM r → dept {10,20,30,40,50}; erin value 50. - # p25=20 median=30 p75=40 range=[10,50] - - name: "m365_emails_read — custom scope (department of 5)" + - name: collab.emails_read request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-23' and metric_date le '2026-12-29'" + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-01", to: "2026-12-31" } + metrics: + - metric_key: collab.emails_read + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } expect: - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: m365_emails_read } - equal: { value: 50, median: 30, range_min: 10, range_max: 50, p25: 20, p75: 40 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "m365_emails_read — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" + - metric: collab.emails_read + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: collab.emails_read + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: collab.emails_read + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 50.0))" + - metric: collab.emails_read + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool') && double(v.value) == 50.0)" diff --git a/src/ingestion/tests/e2e/metrics/collab_emails_received.test.yaml b/src/ingestion/tests/e2e/metrics/collab_emails_received.test.yaml index daf300184..35de55814 100644 --- a/src/ingestion/tests/e2e/metrics/collab_emails_received.test.yaml +++ b/src/ingestion/tests/e2e/metrics/collab_emails_received.test.yaml @@ -31,39 +31,33 @@ bronze: - { $ref: templates/m365_email.yaml#/templates/erin_email, reportRefreshDate: "2026-12-25", unique_key: recv-erin-20261225, receiveCount: 50 } # r=50 (the requested IC member) cases: - # ── CUSTOM scope (Dec 23 .. Dec 29) — department of 5 ──────────────────────── - # per-person SUM r → dept {10,20,30,40,50}; erin value 50. - # p25=20 median=30 p75=40 range=[10,50] - - name: "m365_emails_received — custom scope (department of 5)" + - name: collab.emails_received request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-23' and metric_date le '2026-12-29'" + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-01", to: "2026-12-31" } + metrics: + - metric_key: collab.emails_received + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } expect: - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: m365_emails_received } - equal: { value: 50, median: 30, range_min: 10, range_max: 50, p25: 20, p75: 40 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "m365_emails_received — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" + - metric: collab.emails_received + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: collab.emails_received + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: collab.emails_received + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 50.0))" + - metric: collab.emails_received + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool') && double(v.value) == 50.0)" diff --git a/src/ingestion/tests/e2e/metrics/collab_emails_sent.test.yaml b/src/ingestion/tests/e2e/metrics/collab_emails_sent.test.yaml index 759877314..65a3370af 100644 --- a/src/ingestion/tests/e2e/metrics/collab_emails_sent.test.yaml +++ b/src/ingestion/tests/e2e/metrics/collab_emails_sent.test.yaml @@ -36,40 +36,33 @@ bronze: - { $ref: templates/m365_email.yaml#/templates/erin_email, reportRefreshDate: "2026-12-25", unique_key: sent-erin-20261225, sendCount: 50 } # r=50 (the requested IC member) cases: - # ── CUSTOM scope (Dec 23 .. Dec 29) ────────────────────────────────────────── - # erin's per-person SUM = 50; dept {10,20,30,40,50}. - # p25=20 median=30 p75=40 range=[10,50] - - name: "m365_emails_sent — custom scope (department of 5)" + - name: collab.emails_sent request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-23' and metric_date le '2026-12-29'" + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-01", to: "2026-12-31" } + metrics: + - metric_key: collab.emails_sent + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } expect: - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: m365_emails_sent } - equal: { value: 50, median: 30, range_min: 10, range_max: 50, p25: 20, p75: 40 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - # a valid window with no seeded rows → no items. - - name: "m365_emails_sent — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" + - metric: collab.emails_sent + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: collab.emails_sent + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: collab.emails_sent + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 50.0))" + - metric: collab.emails_sent + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool') && double(v.value) == 50.0)" diff --git a/src/ingestion/tests/e2e/metrics/collab_files_engaged.test.yaml b/src/ingestion/tests/e2e/metrics/collab_files_engaged.test.yaml index 2044bbef8..52f414915 100644 --- a/src/ingestion/tests/e2e/metrics/collab_files_engaged.test.yaml +++ b/src/ingestion/tests/e2e/metrics/collab_files_engaged.test.yaml @@ -36,39 +36,33 @@ bronze: - { $ref: templates/m365_onedrive.yaml#/templates/m365_onedrive, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: eng-erin-20261225, viewedOrEditedFileCount: 50 } # r=50 (the requested IC member; inline base + UPN override) cases: - # ── CUSTOM scope (Dec 23 .. Dec 29) — department of 5 ──────────────────────── - # per-person SUM r → dept {10,20,30,40,50}; erin value 50. - # p25=20 median=30 p75=40 range=[10,50] - - name: "m365_files_engaged — custom scope (department of 5)" + - name: collab.files_engaged request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-23' and metric_date le '2026-12-29'" + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-01", to: "2026-12-31" } + metrics: + - metric_key: collab.files_engaged + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } expect: - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: m365_files_engaged } - equal: { value: 50, median: 30, range_min: 10, range_max: 50, p25: 20, p75: 40 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "m365_files_engaged — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" + - metric: collab.files_engaged + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: collab.files_engaged + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: collab.files_engaged + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 50.0))" + - metric: collab.files_engaged + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool') && double(v.value) == 50.0)" diff --git a/src/ingestion/tests/e2e/metrics/collab_files_shared_external.test.yaml b/src/ingestion/tests/e2e/metrics/collab_files_shared_external.test.yaml index 7c8887582..9518250c9 100644 --- a/src/ingestion/tests/e2e/metrics/collab_files_shared_external.test.yaml +++ b/src/ingestion/tests/e2e/metrics/collab_files_shared_external.test.yaml @@ -35,39 +35,33 @@ bronze: - { $ref: templates/m365_onedrive.yaml#/templates/m365_onedrive, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: ext-erin-20261225, sharedExternallyFileCount: 50 } # r=50 (the requested IC member; inline base + UPN override) cases: - # ── CUSTOM scope (Dec 23 .. Dec 29) — department of 5 ──────────────────────── - # per-person SUM r → dept {10,20,30,40,50}; erin value 50. - # p25=20 median=30 p75=40 range=[10,50] - - name: "m365_files_shared_external — custom scope (department of 5)" + - name: collab.files_shared_external request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-23' and metric_date le '2026-12-29'" + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-01", to: "2026-12-31" } + metrics: + - metric_key: collab.files_shared_external + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } expect: - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: m365_files_shared_external } - equal: { value: 50, median: 30, range_min: 10, range_max: 50, p25: 20, p75: 40 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "m365_files_shared_external — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" + - metric: collab.files_shared_external + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: collab.files_shared_external + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: collab.files_shared_external + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 50.0))" + - metric: collab.files_shared_external + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool') && double(v.value) == 50.0)" diff --git a/src/ingestion/tests/e2e/metrics/collab_files_shared_internal.test.yaml b/src/ingestion/tests/e2e/metrics/collab_files_shared_internal.test.yaml index c3870007b..d73c57327 100644 --- a/src/ingestion/tests/e2e/metrics/collab_files_shared_internal.test.yaml +++ b/src/ingestion/tests/e2e/metrics/collab_files_shared_internal.test.yaml @@ -35,39 +35,53 @@ bronze: - { $ref: templates/m365_onedrive.yaml#/templates/m365_onedrive, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: int-erin-20261225, sharedInternallyFileCount: 50 } # r=50 (the requested IC member; inline base + UPN override) cases: - # ── CUSTOM scope (Dec 23 .. Dec 29) — department of 5 ──────────────────────── - # per-person SUM r → dept {10,20,30,40,50}; erin value 50. - # p25=20 median=30 p75=40 range=[10,50] - - name: "m365_files_shared_internal — custom scope (department of 5)" + - name: collab.files_shared_internal request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-23' and metric_date le '2026-12-29'" + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-01", to: "2026-12-31" } + metrics: + - metric_key: collab.files_shared_internal + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } + - metric_key: collab.files_shared + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [scope] } expect: - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: m365_files_shared_internal } - equal: { value: 50, median: 30, range_min: 10, range_max: 50, p25: 20, p75: 40 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "m365_files_shared_internal — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" + - metric: collab.files_shared_internal + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: collab.files_shared_internal + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: collab.files_shared_internal + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 50.0))" + - metric: collab.files_shared_internal + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool') && double(v.value) == 50.0)" + - metric: collab.files_shared + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: collab.files_shared + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: collab.files_shared + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 50.0))" + - metric: collab.files_shared + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'scope' && d.value == 'internal') && double(v.value) == 50.0)" diff --git a/src/ingestion/tests/e2e/metrics/collab_meeting_free.test.yaml b/src/ingestion/tests/e2e/metrics/collab_meeting_free.test.yaml deleted file mode 100644 index db28821f9..000000000 --- a/src/ingestion/tests/e2e/metrics/collab_meeting_free.test.yaml +++ /dev/null @@ -1,91 +0,0 @@ -spec_version: 1 -description: > - Metric: meeting_free — IC Bullet Collaboration (…0012), #1413. - How it's computed (bronze → silver → gold): - • bronze: daily Teams meeting report + Zoom participant sessions per person - • silver: both sources deduped to per-person/day meeting durations (audio/video/screen) - • gold: a DAY is "meeting-free" if a meeting row EXISTS that day but its total - duration = 0 (no row at all does NOT count); the metric is the COUNT of - such days over the window. value = the requested member's meeting-free - day count; median/p25/p75/range = the distribution of those per-person - counts across the member's DEPARTMENT (same org_unit_id). - - Count-of-days design (this metric counts DAYS, not magnitudes): each member m ∈ - {alice:1, bob:2, carol:3, dave:4, erin:5} is seeded m zero-duration Teams rows — - meetingsAttendedCount:0 and PT0S audio/video/screen — on m distinct days inside - the queried window, so the per-person meeting-free count = m → department - {1,2,3,4,5}. The IC case requests erin (m=5), so value = 5; the department of 5 - yields the median/p25/p75/range below (a reviewer can verify by counting each - member's seeded dates). - - dave/erin Teams rows are composed inline from the base m365_teams template with a - userPrincipalName override (named per-person Teams records cover alice/bob/carol). - Cases: custom scope (department of 5) · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_m365.teams_activity: - # ── alice: 1 meeting-free day(s) in window - - { $ref: templates/m365_teams.yaml#/templates/alice_teams, reportRefreshDate: "2026-12-23", unique_key: mf-alice-20261223, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - # ── bob: 2 meeting-free day(s) in window - - { $ref: templates/m365_teams.yaml#/templates/bob_teams, reportRefreshDate: "2026-12-23", unique_key: mf-bob-20261223, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/bob_teams, reportRefreshDate: "2026-12-24", unique_key: mf-bob-20261224, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - # ── carol: 3 meeting-free day(s) in window - - { $ref: templates/m365_teams.yaml#/templates/carol_teams, reportRefreshDate: "2026-12-23", unique_key: mf-carol-20261223, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/carol_teams, reportRefreshDate: "2026-12-24", unique_key: mf-carol-20261224, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/carol_teams, reportRefreshDate: "2026-12-25", unique_key: mf-carol-20261225, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - # ── dave: 4 meeting-free day(s) in window - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-23", unique_key: mf-dave-20261223, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-24", unique_key: mf-dave-20261224, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-25", unique_key: mf-dave-20261225, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-26", unique_key: mf-dave-20261226, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - # ── erin: 5 meeting-free day(s) in window - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-23", unique_key: mf-erin-20261223, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-24", unique_key: mf-erin-20261224, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: mf-erin-20261225, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-26", unique_key: mf-erin-20261226, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-27", unique_key: mf-erin-20261227, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - -cases: - # ── CUSTOM scope (Dec 23 .. Dec 29) — department of 5 ──────────────────────── - # meeting-free day counts → dept {1,2,3,4,5}; erin value 5. - # p25=2 median=3 p75=4 range=[1,5] - - name: "meeting_free — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-23' and metric_date le '2026-12-29'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: meeting_free } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - # ── EMPTY window (no qualifying days in range) ─────────────────────────────── - - name: "meeting_free — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/collab_meeting_free_days.test.yaml b/src/ingestion/tests/e2e/metrics/collab_meeting_free_days.test.yaml new file mode 100644 index 000000000..3322f6479 --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/collab_meeting_free_days.test.yaml @@ -0,0 +1,81 @@ +spec_version: 1 +description: > + Metric: collab.meeting_free_days. + How it's computed (bronze → silver → gold): + • bronze: daily Teams meeting report + Zoom participant sessions per person + • silver: both sources deduped to per-person/day meeting durations (audio/video/screen) + • gold: a DAY is "meeting-free" when the person has deliberate collaboration + activity but no meeting time; the metric is the COUNT of such days over + the window. value = the requested member's meeting-free + day count; median/p25/p75/range = the distribution of those per-person + counts across the member's DEPARTMENT (same org_unit_id). + + Count-of-days design (this metric counts DAYS, not magnitudes): each member m ∈ + {alice:1, bob:2, carol:3, dave:4, erin:5} is seeded m active, zero-duration Teams + rows — one chat message, no attended meetings, and PT0S audio/video/screen — on m distinct days inside + the queried window, so the per-person meeting-free count = m → department + {1,2,3,4,5}. The IC case requests erin (m=5), so value = 5; the department of 5 + yields the median/p25/p75/range below (a reviewer can verify by counting each + member's seeded dates). + + dave/erin Teams rows are composed inline from the base m365_teams template with a + userPrincipalName override (named per-person Teams records cover alice/bob/carol). + Cases: custom scope (department of 5) · empty window. + +bronze: + bronze_bamboohr.employees: + - $ref: templates/people.yaml#/templates/alice + - $ref: templates/people.yaml#/templates/bob + - $ref: templates/people.yaml#/templates/carol + - $ref: templates/people.yaml#/templates/dave + - $ref: templates/people.yaml#/templates/erin + + bronze_m365.teams_activity: + # ── alice: 1 meeting-free day(s) in window + - { $ref: templates/m365_teams.yaml#/templates/alice_teams, reportRefreshDate: "2026-12-23", unique_key: mf-alice-20261223, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + # ── bob: 2 meeting-free day(s) in window + - { $ref: templates/m365_teams.yaml#/templates/bob_teams, reportRefreshDate: "2026-12-23", unique_key: mf-bob-20261223, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/bob_teams, reportRefreshDate: "2026-12-24", unique_key: mf-bob-20261224, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + # ── carol: 3 meeting-free day(s) in window + - { $ref: templates/m365_teams.yaml#/templates/carol_teams, reportRefreshDate: "2026-12-23", unique_key: mf-carol-20261223, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/carol_teams, reportRefreshDate: "2026-12-24", unique_key: mf-carol-20261224, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/carol_teams, reportRefreshDate: "2026-12-25", unique_key: mf-carol-20261225, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + # ── dave: 4 meeting-free day(s) in window + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-23", unique_key: mf-dave-20261223, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-24", unique_key: mf-dave-20261224, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-25", unique_key: mf-dave-20261225, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-26", unique_key: mf-dave-20261226, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + # ── erin: 5 meeting-free day(s) in window + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-23", unique_key: mf-erin-20261223, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-24", unique_key: mf-erin-20261224, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: mf-erin-20261225, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-26", unique_key: mf-erin-20261226, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-27", unique_key: mf-erin-20261227, teamChatMessageCount: 1, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } + +cases: + - name: collab.meeting_free_days + request: + url: /v1/metric-results + method: POST + body: + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-01", to: "2026-12-31" } + metrics: + - metric_key: collab.meeting_free_days + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + expect: + - assert: "status == 200" + - metric: collab.meeting_free_days + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: collab.meeting_free_days + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: collab.meeting_free_days + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 1.0))" diff --git a/src/ingestion/tests/e2e/metrics/collab_meeting_free_zoom.test.yaml b/src/ingestion/tests/e2e/metrics/collab_meeting_free_zoom.test.yaml deleted file mode 100644 index e12416032..000000000 --- a/src/ingestion/tests/e2e/metrics/collab_meeting_free_zoom.test.yaml +++ /dev/null @@ -1,78 +0,0 @@ -spec_version: 1 -description: > - Metric: meeting_free, Zoom leg — IC Bullet Collaboration (…0012), PR #1746. - The base Teams-only fixture is collab_meeting_free.test.yaml; this one proves - Zoom activity CANCELS a meeting-free day. - How it's computed (bronze → silver → gold): - • bronze: daily Teams meeting report + Zoom participant sessions per person - • silver: both sources deduped to per-person/day meeting durations - • gold: a DAY is "meeting-free" if a meeting row EXISTS that day but the - total duration ACROSS BOTH SOURCES = 0 — so a zero-duration Teams - day stops being meeting-free the moment the person sat in a Zoom - meeting that day; the metric is the COUNT of such days over the - window. value = the requested member's meeting-free day count; - median/p25/p75/range = the distribution across the DEPARTMENT. - - Department: all 5 Engineering members get one zero-duration Teams row on - 2026-12-23 (a meeting-free-day candidate). erin ALSO has a 1h Zoom meeting on - 2026-12-23 → her day is NOT meeting-free. Counts {alice 1, bob 1, carol 1, - dave 1, erin 0}; the IC case requests erin, so value = 0; the department of 5 - yields median 1, p25 1, p75 1, range [0,1] — if the Zoom leg were ignored the - value would be 1 and range_min 0 would not occur. - Cases: custom scope (single window over the department) · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_m365.teams_activity: - # zero-duration Teams day for everyone → 5 meeting-free-day candidates - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-23", unique_key: mfz-alice-20261223, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: bob@example.com, reportRefreshDate: "2026-12-23", unique_key: mfz-bob-20261223, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: carol@example.com, reportRefreshDate: "2026-12-23", unique_key: mfz-carol-20261223, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-23", unique_key: mfz-dave-20261223, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-23", unique_key: mfz-erin-20261223, meetingsAttendedCount: 0, audioDuration: PT0S, videoDuration: PT0S, screenShareDuration: PT0S } - - bronze_zoom.participants: - # erin's 1h Zoom meeting on the SAME day cancels her meeting-free day - - { $ref: templates/zoom.yaml#/templates/erin_zoom, unique_key: mfz-z-erin-1, meeting_uuid: "mfz-e1==", participant_uuid: "mfz-e1-p1", join_time: "2026-12-23T15:00:00Z", leave_time: "2026-12-23T16:00:00Z" } - -cases: - # dept {1,1,1,1,0}; erin value 0. p25=1 median=1 p75=1 range=[0,1] - - name: "meeting_free — Zoom activity cancels the meeting-free day (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-01' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: meeting_free } - equal: { value: 0, median: 1, range_min: 0, range_max: 1, p25: 1, p75: 1 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "meeting_free zoom leg — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/collab_meeting_hours.test.yaml b/src/ingestion/tests/e2e/metrics/collab_meeting_hours.test.yaml deleted file mode 100644 index a5209955c..000000000 --- a/src/ingestion/tests/e2e/metrics/collab_meeting_hours.test.yaml +++ /dev/null @@ -1,70 +0,0 @@ -spec_version: 1 -description: > - Metric: meeting_hours — IC Bullet Collaboration (…0012), #1413. - How it's computed (bronze → silver → gold): - • bronze: daily Teams meeting report + Zoom participant sessions per person - • silver: both sources deduped to per-person/day durations (audio/video/screen) - • gold: per meeting take the longest single modality; value = the requested - member's per-person SUM of those hours over the window, across - Teams + Zoom (additive; Zulip is chat-only — though seeded - Teams-only here). median/p25/p75/range = the distribution of those - per-person SUMs across the member's DEPARTMENT (org_unit_id). - - Department: 5 Engineering members (alice, bob, carol, dave, erin) with meeting - hours {1,2,3,4,5}, each seeded as one Teams meeting-row dated 2026-12-25 carrying - AUDIO-only duration (video / screen = PT0S) so the longest single modality is - exactly the audio hours. The IC case requests erin (5), so value = 5; the - department of 5 yields median 3, p25 2, p75 4, range [1,5]. - Cases: custom scope (single window over the department) · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_m365.teams_activity: - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: mh-alice-20261225, audioDuration: PT1H, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: mh-alice-20261225, audioDuration: PT1H, videoDuration: PT0S, screenShareDuration: PT0S } # duplicate → dedup, not 2h - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: bob@example.com, reportRefreshDate: "2026-12-25", unique_key: mh-bob-20261225, audioDuration: PT2H, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: carol@example.com, reportRefreshDate: "2026-12-25", unique_key: mh-carol-20261225, audioDuration: PT3H, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-25", unique_key: mh-dave-20261225, audioDuration: PT4H, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: mh-erin-20261225, audioDuration: PT5H, videoDuration: PT0S, screenShareDuration: PT0S } - -cases: - # dept {1,2,3,4,5}; erin value 5. p25=2 median=3 p75=4 range=[1,5] - - name: "meeting_hours — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-01' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: meeting_hours } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "meeting_hours — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/collab_meeting_hours_zoom_cross.test.yaml b/src/ingestion/tests/e2e/metrics/collab_meeting_hours_zoom_cross.test.yaml deleted file mode 100644 index 50009a0a1..000000000 --- a/src/ingestion/tests/e2e/metrics/collab_meeting_hours_zoom_cross.test.yaml +++ /dev/null @@ -1,78 +0,0 @@ -spec_version: 1 -description: > - Metric: meeting_hours, Teams+Zoom cross-source additivity — IC Bullet - Collaboration (…0012), PR #1746. The base Teams-only fixture is - collab_meeting_hours.test.yaml; this one proves the Zoom leg is ADDED. - How it's computed (bronze → silver → gold): - • bronze: daily Teams meeting report + Zoom participant sessions per person - • silver: each source deduped to per-person/day durations - • gold: per source-day take the longest single modality, then value = the - requested member's SUM across BOTH sources over the window - (Teams + Zoom additive). median/p25/p75/range = the distribution - of those per-person SUMs across the member's DEPARTMENT. - - Department: 5 Engineering members, each with Teams hours {1,2,3,4,5} PLUS one - 1h Zoom meeting on the same day (2026-12-25) → totals {2,3,4,5,6}. The IC case - requests erin (5+1=6), so value = 6; the department of 5 yields median 4, - p25 3, p75 5, range [2,6] — distinguishable from the Teams-only distribution - {1,2,3,4,5} (median 3), so a dropped Zoom leg fails every asserted field. - Cases: custom scope (single window over the department) · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_m365.teams_activity: - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: mhx-alice-20261225, audioDuration: PT1H, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: bob@example.com, reportRefreshDate: "2026-12-25", unique_key: mhx-bob-20261225, audioDuration: PT2H, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: carol@example.com, reportRefreshDate: "2026-12-25", unique_key: mhx-carol-20261225, audioDuration: PT3H, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-25", unique_key: mhx-dave-20261225, audioDuration: PT4H, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: mhx-erin-20261225, audioDuration: PT5H, videoDuration: PT0S, screenShareDuration: PT0S } - - bronze_zoom.participants: - # one 1h Zoom meeting each, same day as the Teams hours → strictly additive - - { $ref: templates/zoom.yaml#/templates/alice_zoom, unique_key: mhx-z-alice-1, meeting_uuid: "mhx-a1==", participant_uuid: "mhx-a1-p1", join_time: "2026-12-25T15:00:00Z", leave_time: "2026-12-25T16:00:00Z" } - - { $ref: templates/zoom.yaml#/templates/bob_zoom, unique_key: mhx-z-bob-1, meeting_uuid: "mhx-b1==", participant_uuid: "mhx-b1-p1", join_time: "2026-12-25T15:00:00Z", leave_time: "2026-12-25T16:00:00Z" } - - { $ref: templates/zoom.yaml#/templates/carol_zoom, unique_key: mhx-z-carol-1, meeting_uuid: "mhx-c1==", participant_uuid: "mhx-c1-p1", join_time: "2026-12-25T15:00:00Z", leave_time: "2026-12-25T16:00:00Z" } - - { $ref: templates/zoom.yaml#/templates/dave_zoom, unique_key: mhx-z-dave-1, meeting_uuid: "mhx-d1==", participant_uuid: "mhx-d1-p1", join_time: "2026-12-25T15:00:00Z", leave_time: "2026-12-25T16:00:00Z" } - - { $ref: templates/zoom.yaml#/templates/erin_zoom, unique_key: mhx-z-erin-1, meeting_uuid: "mhx-e1==", participant_uuid: "mhx-e1-p1", join_time: "2026-12-25T15:00:00Z", leave_time: "2026-12-25T16:00:00Z" } - -cases: - # dept {2,3,4,5,6}; erin value 6. p25=3 median=4 p75=5 range=[2,6] - - name: "meeting_hours — Teams+Zoom additive (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-01' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: meeting_hours } - equal: { value: 6, median: 4, range_min: 2, range_max: 6, p25: 3, p75: 5 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "meeting_hours cross-source — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/collab_meetings.test.yaml b/src/ingestion/tests/e2e/metrics/collab_meetings.test.yaml new file mode 100644 index 000000000..e1c3fe4c9 --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/collab_meetings.test.yaml @@ -0,0 +1,171 @@ +spec_version: 1 +description: > + Metric: teams_meetings — IC Bullet Collaboration (…0012), #1413. + How it's computed (bronze → silver → gold): + • bronze: daily Teams meeting report per person (meetings attended) + • silver: deduped to meetings-attended per person/day + • gold: value = the requested member's per-person SUM of Teams meetings over + the window, Teams-only — the M365 slice of meetings_count (Zoom is + filtered out by definition). median/p25/p75/range = the distribution + of those per-person SUMs across the member's DEPARTMENT (org_unit_id). + + Department: 5 Engineering members (alice, bob, carol, dave, erin) with meetings + attended {1,2,3,4,5}, each seeded as one Teams meeting-row dated 2026-12-25. + The IC case requests erin (5), so value = 5; the department of 5 yields + median 3, p25 2, p75 4, range [1,5] (quantileExact over the ascending peers). + Cases: custom scope (single window over the department) · empty window. + +bronze: + bronze_bamboohr.employees: + - $ref: templates/people.yaml#/templates/alice + - $ref: templates/people.yaml#/templates/bob + - $ref: templates/people.yaml#/templates/carol + - $ref: templates/people.yaml#/templates/dave + - $ref: templates/people.yaml#/templates/erin + + bronze_m365.teams_activity: + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: tm-alice-20261225, meetingsAttendedCount: 1, meetingsOrganizedCount: 1, adHocMeetingsAttendedCount: 1, scheduledOneTimeMeetingsAttendedCount: 1, audioDuration: PT1H, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: tm-alice-20261225, meetingsAttendedCount: 1, meetingsOrganizedCount: 1, adHocMeetingsAttendedCount: 1, scheduledOneTimeMeetingsAttendedCount: 1, audioDuration: PT1H, videoDuration: PT0S, screenShareDuration: PT0S } # duplicate → dedup, not 2 + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: bob@example.com, reportRefreshDate: "2026-12-25", unique_key: tm-bob-20261225, meetingsAttendedCount: 2, meetingsOrganizedCount: 2, adHocMeetingsAttendedCount: 2, scheduledOneTimeMeetingsAttendedCount: 2, audioDuration: PT2H, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: carol@example.com, reportRefreshDate: "2026-12-25", unique_key: tm-carol-20261225, meetingsAttendedCount: 3, meetingsOrganizedCount: 3, adHocMeetingsAttendedCount: 3, scheduledOneTimeMeetingsAttendedCount: 3, audioDuration: PT3H, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-25", unique_key: tm-dave-20261225, meetingsAttendedCount: 4, meetingsOrganizedCount: 4, adHocMeetingsAttendedCount: 4, scheduledOneTimeMeetingsAttendedCount: 4, audioDuration: PT4H, videoDuration: PT0S, screenShareDuration: PT0S } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: tm-erin-20261225, meetingsAttendedCount: 5, meetingsOrganizedCount: 5, adHocMeetingsAttendedCount: 5, scheduledOneTimeMeetingsAttendedCount: 5, audioDuration: PT5H, videoDuration: PT0S, screenShareDuration: PT0S } + +cases: + - name: Unified collaboration meeting metrics + request: + url: /v1/metric-results + method: POST + body: + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-01", to: "2026-12-31" } + metrics: + - metric_key: collab.meeting_hours + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } + - metric_key: collab.meetings_count + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } + - metric_key: collab.meetings_organized + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } + - metric_key: collab.adhoc_meetings + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } + - metric_key: collab.scheduled_meetings + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } + - metric_key: collab.focus_time_pct + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + expect: + - assert: "status == 200" + - metric: collab.meeting_hours + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: collab.meeting_hours + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: collab.meeting_hours + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { value: 5 } } + - metric: collab.meeting_hours + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: tool, value: m365 } } + equal: { value: 5 } + - metric: collab.meetings_count + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: collab.meetings_count + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: collab.meetings_count + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { value: 5 } } + - metric: collab.meetings_count + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: tool, value: m365 } } + equal: { value: 5 } + - metric: collab.meetings_organized + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: collab.meetings_organized + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: collab.meetings_organized + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { value: 5 } } + - metric: collab.meetings_organized + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: tool, value: m365 } } + equal: { value: 5 } + - metric: collab.adhoc_meetings + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: collab.adhoc_meetings + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: collab.adhoc_meetings + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { value: 5 } } + - metric: collab.adhoc_meetings + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: tool, value: m365 } } + equal: { value: 5 } + - metric: collab.scheduled_meetings + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: collab.scheduled_meetings + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: collab.scheduled_meetings + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { value: 5 } } + - metric: collab.scheduled_meetings + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: tool, value: m365 } } + equal: { value: 5 } + - metric: collab.focus_time_pct + view: period + find: { entity_id: erin@example.com } + equal: { value: 37.5 } + - metric: collab.focus_time_pct + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 37.5, p25: 50, median: 62.5, p75: 75, min: 37.5, max: 87.5, n: 5 } + - metric: collab.focus_time_pct + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { value: 37.5 } } diff --git a/src/ingestion/tests/e2e/metrics/collab_meetings_count.test.yaml b/src/ingestion/tests/e2e/metrics/collab_meetings_count.test.yaml deleted file mode 100644 index 07718b2c6..000000000 --- a/src/ingestion/tests/e2e/metrics/collab_meetings_count.test.yaml +++ /dev/null @@ -1,77 +0,0 @@ -spec_version: 1 -description: > - Metric: meetings_count — IC Bullet Collaboration (…0012), #1413. - How it's computed (bronze → silver → gold): - • bronze: daily Teams meeting report per person (meetings attended) - • silver: deduped to meetings-attended per person/day - • gold: value = the requested member's per-person SUM of meetings attended - over the window, across Teams + Zoom (additive — though seeded - Teams-only here, see the Zoom note below). median/p25/p75/range = - the distribution of those per-person SUMs across the member's - DEPARTMENT (org_unit_id). - - Department: 5 Engineering members (alice, bob, carol, dave, erin) with meetings - attended {1,2,3,4,5}, each seeded as one Teams meeting-row dated 2026-12-25. - The IC case requests erin (5), so value = 5; the department of 5 yields - median 3, p25 2, p75 4, range [1,5]. - - Zoom note: this fixture stays Teams-only — each member's value is exactly - meetingsAttendedCount. Teams+Zoom cross-source additivity has its own fixture - (collab_meetings_count_zoom_cross.test.yaml), and the Zoom-only count path is - pinned by collab_zoom_meetings.test.yaml. (The historical blocker — the - bronze_zoom.participants placeholder was RMT ORDER BY email, collapsing all of - a person's rows on merge — is fixed: the placeholder now mirrors the promoted - table, ORDER BY unique_key.) - Cases: custom scope (single window over the department) · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_m365.teams_activity: - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: mc-alice-20261225, meetingsAttendedCount: 1 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: mc-alice-20261225, meetingsAttendedCount: 1 } # duplicate → dedup, not 2 - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: bob@example.com, reportRefreshDate: "2026-12-25", unique_key: mc-bob-20261225, meetingsAttendedCount: 2 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: carol@example.com, reportRefreshDate: "2026-12-25", unique_key: mc-carol-20261225, meetingsAttendedCount: 3 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-25", unique_key: mc-dave-20261225, meetingsAttendedCount: 4 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: mc-erin-20261225, meetingsAttendedCount: 5 } - -cases: - # dept {1,2,3,4,5}; erin value 5. p25=2 median=3 p75=4 range=[1,5] - - name: "meetings_count — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-01' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: meetings_count } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "meetings_count — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/collab_meetings_count_zoom_cross.test.yaml b/src/ingestion/tests/e2e/metrics/collab_meetings_count_zoom_cross.test.yaml deleted file mode 100644 index 1c573cf97..000000000 --- a/src/ingestion/tests/e2e/metrics/collab_meetings_count_zoom_cross.test.yaml +++ /dev/null @@ -1,80 +0,0 @@ -spec_version: 1 -description: > - Metric: meetings_count, Teams+Zoom cross-source additivity — IC Bullet - Collaboration (…0012), PR #1746. The base Teams-only fixture is - collab_meetings_count.test.yaml; this one proves the Zoom leg is ADDED. - How it's computed (bronze → silver → gold): - • bronze: daily Teams meeting report (meetings attended) + Zoom participant - sessions per person - • silver: Teams deduped to meetings-attended per person/day; Zoom counted as - distinct meetings per person/day - • gold: value = the requested member's per-person SUM of meetings attended - over the window across BOTH sources (Teams + Zoom additive). - median/p25/p75/range = the distribution of those per-person SUMs - across the member's DEPARTMENT (org_unit_id). - - Department: 5 Engineering members, each with Teams meetings {1,2,3,4,5} PLUS - one Zoom meeting on the same day (2026-12-25) → totals {2,3,4,5,6}. The IC - case requests erin (5+1=6), so value = 6; the department of 5 yields median 4, - p25 3, p75 5, range [2,6] — distinguishable from the Teams-only distribution - {1,2,3,4,5} (median 3), so a dropped Zoom leg fails every asserted field. - Cases: custom scope (single window over the department) · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_m365.teams_activity: - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: mcx-alice-20261225, meetingsAttendedCount: 1 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: bob@example.com, reportRefreshDate: "2026-12-25", unique_key: mcx-bob-20261225, meetingsAttendedCount: 2 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: carol@example.com, reportRefreshDate: "2026-12-25", unique_key: mcx-carol-20261225, meetingsAttendedCount: 3 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-25", unique_key: mcx-dave-20261225, meetingsAttendedCount: 4 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: mcx-erin-20261225, meetingsAttendedCount: 5 } - - bronze_zoom.participants: - # one Zoom meeting each, same day as the Teams counts → strictly additive - - { $ref: templates/zoom.yaml#/templates/alice_zoom, unique_key: mcx-z-alice-1, meeting_uuid: "mcx-a1==", participant_uuid: "mcx-a1-p1", join_time: "2026-12-25T15:00:00Z", leave_time: "2026-12-25T15:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/bob_zoom, unique_key: mcx-z-bob-1, meeting_uuid: "mcx-b1==", participant_uuid: "mcx-b1-p1", join_time: "2026-12-25T15:00:00Z", leave_time: "2026-12-25T15:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/carol_zoom, unique_key: mcx-z-carol-1, meeting_uuid: "mcx-c1==", participant_uuid: "mcx-c1-p1", join_time: "2026-12-25T15:00:00Z", leave_time: "2026-12-25T15:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/dave_zoom, unique_key: mcx-z-dave-1, meeting_uuid: "mcx-d1==", participant_uuid: "mcx-d1-p1", join_time: "2026-12-25T15:00:00Z", leave_time: "2026-12-25T15:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/erin_zoom, unique_key: mcx-z-erin-1, meeting_uuid: "mcx-e1==", participant_uuid: "mcx-e1-p1", join_time: "2026-12-25T15:00:00Z", leave_time: "2026-12-25T15:30:00Z" } - -cases: - # dept {2,3,4,5,6}; erin value 6. p25=3 median=4 p75=5 range=[2,6] - - name: "meetings_count — Teams+Zoom additive (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-01' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: meetings_count } - equal: { value: 6, median: 4, range_min: 2, range_max: 6, p25: 3, p75: 5 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "meetings_count cross-source — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/collab_messages.test.yaml b/src/ingestion/tests/e2e/metrics/collab_messages.test.yaml new file mode 100644 index 000000000..6e199d678 --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/collab_messages.test.yaml @@ -0,0 +1,95 @@ +spec_version: 1 +description: "Unified collaboration messaging metrics across period, peer, timeseries, and tool breakdown views." + +bronze: + bronze_bamboohr.employees: + - $ref: templates/people.yaml#/templates/alice + - $ref: templates/people.yaml#/templates/bob + - $ref: templates/people.yaml#/templates/carol + - $ref: templates/people.yaml#/templates/dave + - $ref: templates/people.yaml#/templates/erin + + bronze_m365.teams_activity: + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: tc-alice-20261225, privateChatMessageCount: 10, teamChatMessageCount: 0 } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: tc-alice-20261225, privateChatMessageCount: 10, teamChatMessageCount: 0 } # duplicate → dedup, not 20 + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: bob@example.com, reportRefreshDate: "2026-12-25", unique_key: tc-bob-20261225, privateChatMessageCount: 20, teamChatMessageCount: 0 } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: carol@example.com, reportRefreshDate: "2026-12-25", unique_key: tc-carol-20261225, privateChatMessageCount: 30, teamChatMessageCount: 0 } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-25", unique_key: tc-dave-20261225, privateChatMessageCount: 40, teamChatMessageCount: 0 } + - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: tc-erin-20261225, privateChatMessageCount: 50, teamChatMessageCount: 0 } + +cases: + - name: Unified collaboration messaging + request: + url: /v1/metric-results + method: POST + body: + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-01", to: "2026-12-31" } + metrics: + - metric_key: collab.messages_sent + views: &messaging_views + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [tool] } + - { metric_key: collab.channel_posts, views: *messaging_views } + - { metric_key: collab.dm_ratio, views: *messaging_views } + - { metric_key: collab.msgs_per_active_day, views: *messaging_views } + expect: + - assert: "status == 200" + - metric: collab.messages_sent + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: collab.messages_sent + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: collab.messages_sent + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-12-25' && double(p.value) == 50.0))" + - metric: collab.messages_sent + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool' && d.value == 'm365') && double(v.value) == 50.0)" + - metric: collab.channel_posts + view: period + find: { entity_id: erin@example.com } + equal: { value: 0 } + - metric: collab.channel_posts + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: null, p25: null, median: null, p75: null, min: null, max: null, n: 0 } + - metric: collab.channel_posts + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-12-25' && double(p.value) == 0.0))" + - metric: collab.channel_posts + view: breakdown + assert: "size(items) == 0" + - metric: collab.dm_ratio + view: period + find: { entity_id: erin@example.com } + equal: { value: 100 } + - metric: collab.dm_ratio + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 100, p25: 100, median: 100, p75: 100, min: 100, max: 100, n: 5 } + - metric: collab.dm_ratio + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-12-25' && double(p.value) == 100.0))" + - metric: collab.dm_ratio + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool' && d.value == 'm365') && double(v.value) == 100.0)" + - metric: collab.msgs_per_active_day + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: collab.msgs_per_active_day + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: collab.msgs_per_active_day + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-12-25' && double(p.value) == 50.0))" + - metric: collab.msgs_per_active_day + view: breakdown + assert: "items.exists(v, v.entity_id == 'erin@example.com' && v.dimensions.exists(d, d.key == 'tool' && d.value == 'm365') && double(v.value) == 50.0)" diff --git a/src/ingestion/tests/e2e/metrics/collab_teams_chats.test.yaml b/src/ingestion/tests/e2e/metrics/collab_teams_chats.test.yaml deleted file mode 100644 index 8c6797a7e..000000000 --- a/src/ingestion/tests/e2e/metrics/collab_teams_chats.test.yaml +++ /dev/null @@ -1,68 +0,0 @@ -spec_version: 1 -description: > - Metric: m365_teams_chats — IC Bullet Collaboration (…0012), #1413. - How it's computed (bronze → silver → gold): - • bronze: daily Teams activity report per person (private-DM + team-channel counts) - • silver: deduped to per-person/day; total = private DMs + team-channel messages - • gold: value = the requested member's per-person SUM of Teams chat messages - over the window. median/p25/p75/range = the distribution of those - per-person SUMs across the member's DEPARTMENT (org_unit_id). - - Department: 5 Engineering members (alice, bob, carol, dave, erin) with chat totals - {10,20,30,40,50}, each seeded as one Teams activity-row dated 2026-12-25 carrying - the count in privateChatMessageCount (team = 0); total = private + team. The IC - case requests erin (50), so value = 50; the department of 5 yields median 30, - p25 20, p75 40, range [10,50]. - Cases: custom scope (single window over the department) · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_m365.teams_activity: - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: tc-alice-20261225, privateChatMessageCount: 10, teamChatMessageCount: 0 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: tc-alice-20261225, privateChatMessageCount: 10, teamChatMessageCount: 0 } # duplicate → dedup, not 20 - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: bob@example.com, reportRefreshDate: "2026-12-25", unique_key: tc-bob-20261225, privateChatMessageCount: 20, teamChatMessageCount: 0 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: carol@example.com, reportRefreshDate: "2026-12-25", unique_key: tc-carol-20261225, privateChatMessageCount: 30, teamChatMessageCount: 0 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-25", unique_key: tc-dave-20261225, privateChatMessageCount: 40, teamChatMessageCount: 0 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: tc-erin-20261225, privateChatMessageCount: 50, teamChatMessageCount: 0 } - -cases: - # dept {10,20,30,40,50}; erin value 50. p25=20 median=30 p75=40 range=[10,50] - - name: "m365_teams_chats — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-01' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: m365_teams_chats } - equal: { value: 50, median: 30, range_min: 10, range_max: 50, p25: 20, p75: 40 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "m365_teams_chats — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/collab_teams_meeting_hours.test.yaml b/src/ingestion/tests/e2e/metrics/collab_teams_meeting_hours.test.yaml deleted file mode 100644 index d7c231f08..000000000 --- a/src/ingestion/tests/e2e/metrics/collab_teams_meeting_hours.test.yaml +++ /dev/null @@ -1,70 +0,0 @@ -spec_version: 1 -description: > - Metric: teams_meeting_hours — IC Bullet Collaboration (…0012), #1413. - How it's computed (bronze → silver → gold): - • bronze: daily Teams meeting report per person (audio/video/screen durations) - • silver: deduped to per-person/day durations - • gold: per meeting take the longest single modality; value = the requested - member's per-person SUM of those hours over the window, Teams-only — - the M365 slice of meeting_hours (Zoom is excluded by definition). - median/p25/p75/range = the distribution of those per-person SUMs - across the member's DEPARTMENT (org_unit_id). - - Department: 5 Engineering members (alice, bob, carol, dave, erin) with meeting - hours {1,2,3,4,5}, each seeded as one Teams meeting-row dated 2026-12-25 carrying - AUDIO-only duration (video / screen = PT0S) so the longest single modality is - exactly the audio hours. The IC case requests erin (5), so value = 5; the - department of 5 yields median 3, p25 2, p75 4, range [1,5]. - Cases: custom scope (single window over the department) · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_m365.teams_activity: - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: tmh-alice-20261225, audioDuration: PT1H, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: tmh-alice-20261225, audioDuration: PT1H, videoDuration: PT0S, screenShareDuration: PT0S } # duplicate → dedup, not 2h - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: bob@example.com, reportRefreshDate: "2026-12-25", unique_key: tmh-bob-20261225, audioDuration: PT2H, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: carol@example.com, reportRefreshDate: "2026-12-25", unique_key: tmh-carol-20261225, audioDuration: PT3H, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-25", unique_key: tmh-dave-20261225, audioDuration: PT4H, videoDuration: PT0S, screenShareDuration: PT0S } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: tmh-erin-20261225, audioDuration: PT5H, videoDuration: PT0S, screenShareDuration: PT0S } - -cases: - # dept {1,2,3,4,5}; erin value 5. p25=2 median=3 p75=4 range=[1,5] - - name: "teams_meeting_hours — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-01' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: teams_meeting_hours } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "teams_meeting_hours — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/collab_teams_meetings.test.yaml b/src/ingestion/tests/e2e/metrics/collab_teams_meetings.test.yaml deleted file mode 100644 index 6a39ddb94..000000000 --- a/src/ingestion/tests/e2e/metrics/collab_teams_meetings.test.yaml +++ /dev/null @@ -1,68 +0,0 @@ -spec_version: 1 -description: > - Metric: teams_meetings — IC Bullet Collaboration (…0012), #1413. - How it's computed (bronze → silver → gold): - • bronze: daily Teams meeting report per person (meetings attended) - • silver: deduped to meetings-attended per person/day - • gold: value = the requested member's per-person SUM of Teams meetings over - the window, Teams-only — the M365 slice of meetings_count (Zoom is - filtered out by definition). median/p25/p75/range = the distribution - of those per-person SUMs across the member's DEPARTMENT (org_unit_id). - - Department: 5 Engineering members (alice, bob, carol, dave, erin) with meetings - attended {1,2,3,4,5}, each seeded as one Teams meeting-row dated 2026-12-25. - The IC case requests erin (5), so value = 5; the department of 5 yields - median 3, p25 2, p75 4, range [1,5] (quantileExact over the ascending peers). - Cases: custom scope (single window over the department) · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_m365.teams_activity: - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: tm-alice-20261225, meetingsAttendedCount: 1 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: alice@example.com, reportRefreshDate: "2026-12-25", unique_key: tm-alice-20261225, meetingsAttendedCount: 1 } # duplicate → dedup, not 2 - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: bob@example.com, reportRefreshDate: "2026-12-25", unique_key: tm-bob-20261225, meetingsAttendedCount: 2 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: carol@example.com, reportRefreshDate: "2026-12-25", unique_key: tm-carol-20261225, meetingsAttendedCount: 3 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: dave@example.com, reportRefreshDate: "2026-12-25", unique_key: tm-dave-20261225, meetingsAttendedCount: 4 } - - { $ref: templates/m365_teams.yaml#/templates/m365_teams, userPrincipalName: erin@example.com, reportRefreshDate: "2026-12-25", unique_key: tm-erin-20261225, meetingsAttendedCount: 5 } - -cases: - # dept {1,2,3,4,5}; erin value 5. p25=2 median=3 p75=4 range=[1,5] - - name: "teams_meetings — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-01' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: teams_meetings } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "teams_meetings — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/collab_zoom_meeting_hours.test.yaml b/src/ingestion/tests/e2e/metrics/collab_zoom_meeting_hours.test.yaml deleted file mode 100644 index b4d891ab3..000000000 --- a/src/ingestion/tests/e2e/metrics/collab_zoom_meeting_hours.test.yaml +++ /dev/null @@ -1,81 +0,0 @@ -spec_version: 1 -description: > - Metric: zoom_meeting_hours — IC Bullet Collaboration (…0012), PR #1746. - How it's computed (bronze → silver → gold): - • bronze: Zoom participant sessions (join/leave per person per meeting) - • silver: sessions deduped (a re-synced duplicate row must not double), then - summed to per-person/day durations; video counts only when the - participant used a camera, screen-share only when they shared - • gold: per day take the longest single modality (audio vs video vs screen) - — for Zoom these coincide because the whole session length is - attributed to a modality the participant used; value = the requested - member's per-person SUM of those hours over the window, Zoom rows - only (Teams hours land in teams_/meeting_hours, not here). - median/p25/p75/range = the distribution of those per-person SUMs - across the member's DEPARTMENT (org_unit_id). - - Department: 5 Engineering members with Zoom hours {1,2,3,4,5} on 2026-12-25 — - alice 1×1h (+ an exact duplicate row that must dedup), bob 0.5h+1.5h across TWO - meetings (sums to 2), carol 3h with a camera on (video == audio → longest - modality still 3), dave 4h with desktop share (same, 4), erin 5h audio-only. - The IC case requests erin (5), so value = 5; the department of 5 yields - median 3, p25 2, p75 4, range [1,5]. - Cases: custom scope (single window over the department) · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_zoom.participants: - # ── alice: 1h (one meeting; the duplicate is a verbatim Airbyte re-emit → must NOT double) - - { $ref: templates/zoom.yaml#/templates/alice_zoom, unique_key: zmh-alice-1, meeting_uuid: "zmh-a1==", participant_uuid: "zmh-a1-p1", join_time: "2026-12-25T10:00:00Z", leave_time: "2026-12-25T11:00:00Z" } - - { $ref: templates/zoom.yaml#/templates/alice_zoom, unique_key: zmh-alice-1, meeting_uuid: "zmh-a1==", participant_uuid: "zmh-a1-p1", join_time: "2026-12-25T10:00:00Z", leave_time: "2026-12-25T11:00:00Z" } - # ── bob: 0.5h + 1.5h across two meetings → 2h (per-day SUM across meetings) - - { $ref: templates/zoom.yaml#/templates/bob_zoom, unique_key: zmh-bob-1, meeting_uuid: "zmh-b1==", participant_uuid: "zmh-b1-p1", join_time: "2026-12-25T10:00:00Z", leave_time: "2026-12-25T10:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/bob_zoom, unique_key: zmh-bob-2, meeting_uuid: "zmh-b2==", participant_uuid: "zmh-b2-p1", join_time: "2026-12-25T13:00:00Z", leave_time: "2026-12-25T14:30:00Z" } - # ── carol: 3h with camera on (video == audio; longest modality still 3h) - - { $ref: templates/zoom.yaml#/templates/carol_zoom, unique_key: zmh-carol-1, meeting_uuid: "zmh-c1==", participant_uuid: "zmh-c1-p1", join_time: "2026-12-25T09:00:00Z", leave_time: "2026-12-25T12:00:00Z", camera: "FaceTime HD Camera" } - # ── dave: 4h with desktop share (screen == audio; longest modality still 4h) - - { $ref: templates/zoom.yaml#/templates/dave_zoom, unique_key: zmh-dave-1, meeting_uuid: "zmh-d1==", participant_uuid: "zmh-d1-p1", join_time: "2026-12-25T09:00:00Z", leave_time: "2026-12-25T13:00:00Z", share_desktop: true } - # ── erin: 5h audio-only - - { $ref: templates/zoom.yaml#/templates/erin_zoom, unique_key: zmh-erin-1, meeting_uuid: "zmh-e1==", participant_uuid: "zmh-e1-p1", join_time: "2026-12-25T09:00:00Z", leave_time: "2026-12-25T14:00:00Z" } - -cases: - # dept {1,2,3,4,5}; erin value 5. p25=2 median=3 p75=4 range=[1,5] - - name: "zoom_meeting_hours — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-01' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: zoom_meeting_hours } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "zoom_meeting_hours — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/collab_zoom_meetings.test.yaml b/src/ingestion/tests/e2e/metrics/collab_zoom_meetings.test.yaml deleted file mode 100644 index 702738583..000000000 --- a/src/ingestion/tests/e2e/metrics/collab_zoom_meetings.test.yaml +++ /dev/null @@ -1,100 +0,0 @@ -spec_version: 1 -description: > - Metric: zoom_meetings — IC Bullet Collaboration (…0012), PR #1746. - How it's computed (bronze → silver → gold): - • bronze: Zoom participant sessions (one row per person per session) + the - Zoom meeting-session report (one row per session uuid, carrying the - reusable Meeting ID) - • silver: participant sessions deduped, then counted as DISTINCT logical - meetings per person/day — consecutive sessions of the SAME Zoom - Meeting ID with an end→start gap ≤ 5 min are stitched into ONE - logical meeting (a host-drop rejoin is not a second meeting) - • gold: value = the requested member's per-person SUM of distinct meetings - over the window, Zoom rows only (Teams meetings land in - teams_meetings, not here). median/p25/p75/range = the distribution - of those per-person SUMs across the member's DEPARTMENT (org_unit_id). - - Department: 5 Engineering members with distinct-meeting counts {1,2,3,4,5} on - 2026-12-25 — alice 1 meeting (+ an exact duplicate row that must dedup); bob - 2 = one meeting he dropped from and rejoined 2 min later (two sessions of - Meeting ID 88011, stitched to ONE) plus one ordinary meeting; carol/dave/erin - 3/4/5 ordinary distinct meetings (no meeting-report rows — the count falls - back to one-session-one-meeting). The IC case requests erin (5), so value = 5; - the department of 5 yields median 3, p25 2, p75 4, range [1,5]. - NOTE: the session-stitching report is only read for rows extracted within the - last 90 days of REAL now() — the seeded meeting rows pin _airbyte_extracted_at - next to the meeting date (2026-12-25), which satisfies that until ~2027-03. - Cases: custom scope (single window over the department) · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - # bob's Meeting ID 88011 ran as two sessions 2 min apart → ONE logical meeting. - bronze_zoom.meetings: - - { $ref: templates/zoom.yaml#/templates/zoom_meeting, _airbyte_extracted_at: "2026-12-25T15:00:00", unique_key: zm-88011-s1, id: "88011", uuid: "zm-b1a==", start_time: "2026-12-25T10:00:00Z", end_time: "2026-12-25T10:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/zoom_meeting, _airbyte_extracted_at: "2026-12-25T15:00:00", unique_key: zm-88011-s2, id: "88011", uuid: "zm-b1b==", start_time: "2026-12-25T10:32:00Z", end_time: "2026-12-25T11:00:00Z" } - - bronze_zoom.participants: - # ── alice: 1 meeting (+ verbatim re-emit duplicate → must NOT count as 2) - - { $ref: templates/zoom.yaml#/templates/alice_zoom, unique_key: zm-alice-1, meeting_uuid: "zm-a1==", participant_uuid: "zm-a1-p1", join_time: "2026-12-25T10:00:00Z", leave_time: "2026-12-25T10:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/alice_zoom, unique_key: zm-alice-1, meeting_uuid: "zm-a1==", participant_uuid: "zm-a1-p1", join_time: "2026-12-25T10:00:00Z", leave_time: "2026-12-25T10:30:00Z" } - # ── bob: 2 = stitched rejoin (sessions zm-b1a== + zm-b1b== of Meeting 88011 → 1) + one ordinary meeting - - { $ref: templates/zoom.yaml#/templates/bob_zoom, unique_key: zm-bob-1, meeting_uuid: "zm-b1a==", participant_uuid: "zm-b1-p1", join_time: "2026-12-25T10:00:00Z", leave_time: "2026-12-25T10:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/bob_zoom, unique_key: zm-bob-2, meeting_uuid: "zm-b1b==", participant_uuid: "zm-b1-p2", join_time: "2026-12-25T10:32:00Z", leave_time: "2026-12-25T11:00:00Z" } - - { $ref: templates/zoom.yaml#/templates/bob_zoom, unique_key: zm-bob-3, meeting_uuid: "zm-b2==", participant_uuid: "zm-b2-p1", join_time: "2026-12-25T13:00:00Z", leave_time: "2026-12-25T13:30:00Z" } - # ── carol: 3 distinct meetings - - { $ref: templates/zoom.yaml#/templates/carol_zoom, unique_key: zm-carol-1, meeting_uuid: "zm-c1==", participant_uuid: "zm-c1-p1", join_time: "2026-12-25T09:00:00Z", leave_time: "2026-12-25T09:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/carol_zoom, unique_key: zm-carol-2, meeting_uuid: "zm-c2==", participant_uuid: "zm-c2-p1", join_time: "2026-12-25T11:00:00Z", leave_time: "2026-12-25T11:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/carol_zoom, unique_key: zm-carol-3, meeting_uuid: "zm-c3==", participant_uuid: "zm-c3-p1", join_time: "2026-12-25T14:00:00Z", leave_time: "2026-12-25T14:30:00Z" } - # ── dave: 4 distinct meetings - - { $ref: templates/zoom.yaml#/templates/dave_zoom, unique_key: zm-dave-1, meeting_uuid: "zm-d1==", participant_uuid: "zm-d1-p1", join_time: "2026-12-25T09:00:00Z", leave_time: "2026-12-25T09:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/dave_zoom, unique_key: zm-dave-2, meeting_uuid: "zm-d2==", participant_uuid: "zm-d2-p1", join_time: "2026-12-25T10:00:00Z", leave_time: "2026-12-25T10:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/dave_zoom, unique_key: zm-dave-3, meeting_uuid: "zm-d3==", participant_uuid: "zm-d3-p1", join_time: "2026-12-25T11:00:00Z", leave_time: "2026-12-25T11:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/dave_zoom, unique_key: zm-dave-4, meeting_uuid: "zm-d4==", participant_uuid: "zm-d4-p1", join_time: "2026-12-25T12:00:00Z", leave_time: "2026-12-25T12:30:00Z" } - # ── erin: 5 distinct meetings - - { $ref: templates/zoom.yaml#/templates/erin_zoom, unique_key: zm-erin-1, meeting_uuid: "zm-e1==", participant_uuid: "zm-e1-p1", join_time: "2026-12-25T09:00:00Z", leave_time: "2026-12-25T09:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/erin_zoom, unique_key: zm-erin-2, meeting_uuid: "zm-e2==", participant_uuid: "zm-e2-p1", join_time: "2026-12-25T10:00:00Z", leave_time: "2026-12-25T10:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/erin_zoom, unique_key: zm-erin-3, meeting_uuid: "zm-e3==", participant_uuid: "zm-e3-p1", join_time: "2026-12-25T11:00:00Z", leave_time: "2026-12-25T11:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/erin_zoom, unique_key: zm-erin-4, meeting_uuid: "zm-e4==", participant_uuid: "zm-e4-p1", join_time: "2026-12-25T12:00:00Z", leave_time: "2026-12-25T12:30:00Z" } - - { $ref: templates/zoom.yaml#/templates/erin_zoom, unique_key: zm-erin-5, meeting_uuid: "zm-e5==", participant_uuid: "zm-e5-p1", join_time: "2026-12-25T13:00:00Z", leave_time: "2026-12-25T13:30:00Z" } - -cases: - # dept {1,2,3,4,5}; erin value 5. p25=2 median=3 p75=4 range=[1,5] - - name: "zoom_meetings — custom scope (department of 5, stitched rejoin counts once)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-01' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: zoom_meetings } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "zoom_meetings — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/collab_zulip_chat.test.yaml b/src/ingestion/tests/e2e/metrics/collab_zulip_chat.test.yaml deleted file mode 100644 index 8a313659a..000000000 --- a/src/ingestion/tests/e2e/metrics/collab_zulip_chat.test.yaml +++ /dev/null @@ -1,77 +0,0 @@ -spec_version: 1 -description: > - Metric: zulip_messages_sent — IC Bullet Collaboration (…0012), #1413. - How it's computed (bronze → silver → gold): - • bronze: Zulip users (emails) + per-sender message counts from the Zulip proxy - • silver: deduped by `uniq` to per-person/day chat-message totals - • gold: value = the requested member's per-person SUM of Zulip messages sent - over the window (chat-only). median/p25/p75/range = the distribution - of those per-person SUMs across the member's DEPARTMENT (org_unit_id). - - Department: 5 Engineering members (alice, bob, carol, dave, erin) with message - counts {10,20,30,40,50}. Each member has one Zulip user (sender_id = id) and one - message-row dated 2026-12-25. The IC case requests erin (50), so value = 50; the - department of 5 yields median 30, p25 20, p75 40, range [10,50]. - Cases: custom scope (single window over the department) · empty window. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - # Zulip user directory — messages join on sender_id = id; users join people on - # lower(email) = person_id. alice/bob/carol come from the shared template; - # dave/erin are defined inline off the base zulip_user template. - bronze_zulip_proxy.users: - - $ref: templates/zulip.yaml#/templates/alice_user - - $ref: templates/zulip.yaml#/templates/bob_user - - $ref: templates/zulip.yaml#/templates/carol_user - - { $ref: templates/zulip.yaml#/templates/zulip_user, id: 4, email: dave@example.com, full_name: Dave Delta, unique_key: zulip-user-4 } - - { $ref: templates/zulip.yaml#/templates/zulip_user, id: 5, email: erin@example.com, full_name: Erin Epsilon, unique_key: zulip-user-5 } - - bronze_zulip_proxy.messages: - - { $ref: templates/zulip.yaml#/templates/zulip_message, sender_id: 1, created_at: "2026-12-25T10:00:00", uniq: z-alice-20261225, unique_key: zmsg-alice-20261225, count: 10 } - - { $ref: templates/zulip.yaml#/templates/zulip_message, sender_id: 1, created_at: "2026-12-25T10:00:00", uniq: z-alice-20261225, unique_key: zmsg-alice-20261225, count: 10 } # duplicate uniq → dedup, not 20 - - { $ref: templates/zulip.yaml#/templates/zulip_message, sender_id: 2, created_at: "2026-12-25T10:00:00", uniq: z-bob-20261225, unique_key: zmsg-bob-20261225, count: 20 } - - { $ref: templates/zulip.yaml#/templates/zulip_message, sender_id: 3, created_at: "2026-12-25T10:00:00", uniq: z-carol-20261225, unique_key: zmsg-carol-20261225, count: 30 } - - { $ref: templates/zulip.yaml#/templates/zulip_message, sender_id: 4, created_at: "2026-12-25T10:00:00", uniq: z-dave-20261225, unique_key: zmsg-dave-20261225, count: 40 } - - { $ref: templates/zulip.yaml#/templates/zulip_message, sender_id: 5, created_at: "2026-12-25T10:00:00", uniq: z-erin-20261225, unique_key: zmsg-erin-20261225, count: 50 } - -cases: - # dept {10,20,30,40,50}; erin value 50. p25=20 median=30 p75=40 range=[10,50] - - name: "zulip_messages_sent — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-01' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: zulip_messages_sent } - equal: { value: 50, median: 30, range_min: 10, range_max: 50, p25: 20, p75: 40 } - - # ── EMPTY window (no rows in range) ────────────────────────────────────────── - - name: "zulip_messages_sent — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000012 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/git_metrics.test.yaml b/src/ingestion/tests/e2e/metrics/git_metrics.test.yaml new file mode 100644 index 000000000..c8b0ef058 --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/git_metrics.test.yaml @@ -0,0 +1,265 @@ +spec_version: 1 +description: "Unified Git metrics cover authored commits, file categories, pull requests, cohort merge rate, medians, and histograms." + +bronze: + bronze_bamboohr.employees: + - $ref: templates/people.yaml#/templates/alice + - $ref: templates/people.yaml#/templates/bob + - $ref: templates/people.yaml#/templates/carol + - $ref: templates/people.yaml#/templates/dave + - $ref: templates/people.yaml#/templates/erin + bronze_github.commits: + - { $ref: templates/git_activity.yaml#/templates/commit, unique_key: commit-alice, oid: hash-alice, author_name: alice, author_email: alice@example.com, committer_name: alice, committer_email: alice@example.com, additions: 10 } + - { $ref: templates/git_activity.yaml#/templates/commit, unique_key: commit-bob, oid: hash-bob, author_name: bob, author_email: bob@example.com, committer_name: bob, committer_email: bob@example.com, additions: 20 } + - { $ref: templates/git_activity.yaml#/templates/commit, unique_key: commit-carol, oid: hash-carol, author_name: carol, author_email: carol@example.com, committer_name: carol, committer_email: carol@example.com, additions: 30 } + - { $ref: templates/git_activity.yaml#/templates/commit, unique_key: commit-dave, oid: hash-dave, author_name: dave, author_email: dave@example.com, committer_name: dave, committer_email: dave@example.com, additions: 40 } + - { $ref: templates/git_activity.yaml#/templates/commit, unique_key: commit-erin, oid: hash-erin, author_name: erin, author_email: erin@example.com, committer_name: erin, committer_email: erin@example.com, additions: 50 } + bronze_github.file_changes: + - { $ref: templates/git_activity.yaml#/templates/file_change, unique_key: file-alice, commit_hash: hash-alice, additions: 10 } + - { $ref: templates/git_activity.yaml#/templates/file_change, unique_key: file-bob, commit_hash: hash-bob, additions: 20 } + - { $ref: templates/git_activity.yaml#/templates/file_change, unique_key: file-carol, commit_hash: hash-carol, additions: 30 } + - { $ref: templates/git_activity.yaml#/templates/file_change, unique_key: file-dave, commit_hash: hash-dave, additions: 40 } + - { $ref: templates/git_activity.yaml#/templates/file_change, unique_key: file-erin, commit_hash: hash-erin, additions: 50 } + bronze_github.pull_requests: + - { $ref: templates/git_activity.yaml#/templates/pull_request, unique_key: pr-alice, database_id: 1, number: 1, author_login: alice, author_email: alice@example.com, closed_at: "2026-10-01T09:00:00Z", merge_commit_sha: merge-alice, additions: 10 } + - { $ref: templates/git_activity.yaml#/templates/pull_request, unique_key: pr-bob, database_id: 2, number: 2, author_login: bob, author_email: bob@example.com, closed_at: "2026-10-01T10:00:00Z", merge_commit_sha: merge-bob, additions: 20 } + - { $ref: templates/git_activity.yaml#/templates/pull_request, unique_key: pr-carol, database_id: 3, number: 3, author_login: carol, author_email: carol@example.com, closed_at: "2026-10-01T11:00:00Z", merge_commit_sha: merge-carol, additions: 30 } + - { $ref: templates/git_activity.yaml#/templates/pull_request, unique_key: pr-dave, database_id: 4, number: 4, author_login: dave, author_email: dave@example.com, closed_at: "2026-10-01T12:00:00Z", merge_commit_sha: merge-dave, additions: 40 } + - { $ref: templates/git_activity.yaml#/templates/pull_request, unique_key: pr-erin, database_id: 5, number: 5, author_login: erin, author_email: erin@example.com, closed_at: "2026-10-01T13:00:00Z", merge_commit_sha: merge-erin, additions: 50 } + +cases: + - name: Unified Git metrics + request: + url: /v1/metric-results + method: POST + body: + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-10-01", to: "2026-10-02" } + metrics: + - metric_key: git.commits + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [source] } + - metric_key: git.code_lines + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [source] } + - metric_key: git.lines_added + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [category, source] } + - metric_key: git.prs_created + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [source] } + - metric_key: git.prs_merged + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [source] } + - metric_key: git.merge_rate + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - metric_key: git.commits_per_active_day + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - metric_key: git.commit_size + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [source] } + - { view: histogram } + - metric_key: git.pr_size + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [source] } + - { view: histogram } + - metric_key: git.pr_cycle_time_h + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: breakdown, dimensions: [source] } + - { view: histogram } + expect: + - assert: "status == 200" + - metric: git.commits + view: period + find: { entity_id: erin@example.com } + equal: { value: 1 } + - metric: git.commits + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 1, p25: 1, median: 1, p75: 1, min: 1, max: 1, n: 5 } + - metric: git.commits + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { bucket_start: "2026-10-01", value: 1 } } + - metric: git.commits + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: source, value: github } } + equal: { value: 1 } + - metric: git.code_lines + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: git.code_lines + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: git.code_lines + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { bucket_start: "2026-10-01", value: 50 } } + - metric: git.code_lines + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: source, value: github } } + equal: { value: 50 } + - metric: git.lines_added + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: git.lines_added + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: git.lines_added + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { bucket_start: "2026-10-01", value: 50 } } + - metric: git.lines_added + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: category, value: code } } + equal: { value: 50 } + - metric: git.prs_created + view: period + find: { entity_id: erin@example.com } + equal: { value: 1 } + - metric: git.prs_created + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 1, p25: 1, median: 1, p75: 1, min: 1, max: 1, n: 5 } + - metric: git.prs_created + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { bucket_start: "2026-10-01", value: 1 } } + - metric: git.prs_created + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: source, value: github } } + equal: { value: 1 } + - metric: git.prs_merged + view: period + find: { entity_id: erin@example.com } + equal: { value: 1 } + - metric: git.prs_merged + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 1, p25: 1, median: 1, p75: 1, min: 1, max: 1, n: 5 } + - metric: git.prs_merged + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { bucket_start: "2026-10-01", value: 1 } } + - metric: git.prs_merged + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: source, value: github } } + equal: { value: 1 } + - metric: git.merge_rate + view: period + find: { entity_id: erin@example.com } + equal: { value: 100 } + - metric: git.merge_rate + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 100, p25: 100, median: 100, p75: 100, min: 100, max: 100, n: 5 } + - metric: git.merge_rate + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { bucket_start: "2026-10-01", value: 100 } } + - metric: git.commits_per_active_day + view: period + find: { entity_id: erin@example.com } + equal: { value: 1 } + - metric: git.commits_per_active_day + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 1, p25: 1, median: 1, p75: 1, min: 1, max: 1, n: 5 } + - metric: git.commits_per_active_day + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { bucket_start: "2026-10-01", value: 1 } } + - metric: git.commit_size + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: git.commit_size + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: git.commit_size + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { bucket_start: "2026-10-01", value: 50 } } + - metric: git.commit_size + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: source, value: github } } + equal: { value: 50 } + - metric: git.commit_size + view: histogram + find: { entity_id: erin@example.com } + nonempty: [bins] + - metric: git.pr_size + view: period + find: { entity_id: erin@example.com } + equal: { value: 50 } + - metric: git.pr_size + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 50, p25: 20, median: 30, p75: 40, min: 10, max: 50, n: 5 } + - metric: git.pr_size + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { bucket_start: "2026-10-01", value: 50 } } + - metric: git.pr_size + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: source, value: github } } + equal: { value: 50 } + - metric: git.pr_size + view: histogram + find: { entity_id: erin@example.com } + nonempty: [bins] + - metric: git.pr_cycle_time_h + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: git.pr_cycle_time_h + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: git.pr_cycle_time_h + view: timeseries + find: { entity_id: erin@example.com } + contains: { points: { bucket_start: "2026-10-01", value: 5 } } + - metric: git.pr_cycle_time_h + view: breakdown + find: { entity_id: erin@example.com, dimensions: { key: source, value: github } } + equal: { value: 5 } + - metric: git.pr_cycle_time_h + view: histogram + find: { entity_id: erin@example.com } + nonempty: [bins] diff --git a/src/ingestion/tests/e2e/metrics/schemas/bronze_claude_team.claude_team_overage_spend.yaml b/src/ingestion/tests/e2e/metrics/schemas/bronze_claude_team.claude_team_overage_spend.yaml deleted file mode 100644 index 391bccf96..000000000 --- a/src/ingestion/tests/e2e/metrics/schemas/bronze_claude_team.claude_team_overage_spend.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# JSON schema for bronze_claude_team.claude_team_overage_spend (all real columns). -# Mirrors the connector InlineSchemaLoader (connectors/ai/claude-team/connector.yaml -# — stream claude_team_overage_spend, GET /api/organizations/{org}/overage_spend_limits) -# plus the 4 _airbyte_* CDK columns. NOTE: monthly_credit_limit / used_credits are -# ALREADY in cents (USD minor units) — Silver maps them straight through, no ×100. -# The metric date is the snapshot's collection day (toDate(_airbyte_extracted_at)), -# not a bronze date field. -schemas: - bronze_claude_team.claude_team_overage_spend: - $schema: http://json-schema.org/draft-07/schema# - type: object - additionalProperties: false - properties: - _airbyte_raw_id: { type: string } - _airbyte_extracted_at: { type: string, format: date-time } - _airbyte_meta: { type: string } - _airbyte_generation_id: { type: integer } - tenant_id: { type: string } - source_id: { type: string } - unique_key: { type: string } - collected_at: { type: string } - data_source: { type: string } - account_uuid: { type: string } - account_email: { type: string } - account_name: { type: string } - seat_tier: { type: [string, "null"] } - is_enabled: { type: [boolean, "null"] } - monthly_credit_limit: { type: [number, "null"] } - used_credits: { type: [number, "null"] } - currency: { type: [string, "null"] } - out_of_credits: { type: [boolean, "null"] } - used_credits_basis: { type: [string, "null"] } - limit_type: { type: [string, "null"] } diff --git a/src/ingestion/tests/e2e/metrics/schemas/bronze_confluence.wiki_pages.yaml b/src/ingestion/tests/e2e/metrics/schemas/bronze_confluence.wiki_pages.yaml deleted file mode 100644 index 09b998724..000000000 --- a/src/ingestion/tests/e2e/metrics/schemas/bronze_confluence.wiki_pages.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Auto-generated JSON schema mirroring the connector bronze columns the dbt -# model reads for bronze_confluence.wiki_pages (+ the 4 _airbyte_* CDK columns). -schemas: - bronze_confluence.wiki_pages: - $schema: http://json-schema.org/draft-07/schema# - type: object - additionalProperties: false - properties: - _airbyte_raw_id: { type: string } - _airbyte_extracted_at: { type: string, format: date-time } - _airbyte_meta: { type: string } - _airbyte_generation_id: { type: integer } - unique_key: { type: string } - tenant_id: { type: [string, "null"] } - source_id: { type: [string, "null"] } - page_id: { type: [string, "null"] } - space_id: { type: [string, "null"] } - title: { type: [string, "null"] } - status: { type: [string, "null"] } - author_id: { type: [string, "null"] } - last_editor_id: { type: [string, "null"] } - parent_page_id: { type: [string, "null"] } - version_number: { type: [number, "null"] } - created_at: { type: [string, "null"] } - updated_at: { type: [string, "null"] } - collected_at: { type: [string, "null"] } diff --git a/src/ingestion/tests/e2e/metrics/schemas/bronze_github.commits.yaml b/src/ingestion/tests/e2e/metrics/schemas/bronze_github.commits.yaml new file mode 100644 index 000000000..d00746480 --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/schemas/bronze_github.commits.yaml @@ -0,0 +1,27 @@ +schemas: + bronze_github.commits: + $schema: http://json-schema.org/draft-07/schema# + type: object + additionalProperties: false + properties: + _airbyte_raw_id: { type: string } + _airbyte_extracted_at: { type: string, format: date-time } + _airbyte_meta: { type: string } + _airbyte_generation_id: { type: integer } + tenant_id: { type: string } + source_id: { type: string } + unique_key: { type: string } + repo_owner: { type: string } + repo_name: { type: string } + oid: { type: string } + branch_name: { type: string } + author_name: { type: string } + author_email: { type: string } + committer_name: { type: string } + committer_email: { type: string } + message: { type: string } + committed_date: { type: string } + changed_files: { type: integer } + additions: { type: integer } + deletions: { type: integer } + parent_hashes: { type: array, items: { type: string } } diff --git a/src/ingestion/tests/e2e/metrics/schemas/bronze_github.file_changes.yaml b/src/ingestion/tests/e2e/metrics/schemas/bronze_github.file_changes.yaml new file mode 100644 index 000000000..45e8bf3e8 --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/schemas/bronze_github.file_changes.yaml @@ -0,0 +1,21 @@ +schemas: + bronze_github.file_changes: + $schema: http://json-schema.org/draft-07/schema# + type: object + additionalProperties: false + properties: + _airbyte_raw_id: { type: string } + _airbyte_extracted_at: { type: string, format: date-time } + _airbyte_meta: { type: string } + _airbyte_generation_id: { type: integer } + tenant_id: { type: string } + source_id: { type: string } + unique_key: { type: string } + repo_owner: { type: string } + repo_name: { type: string } + commit_hash: { type: string } + filename: { type: string } + status: { type: string } + additions: { type: integer } + deletions: { type: integer } + source_type: { type: string } diff --git a/src/ingestion/tests/e2e/metrics/schemas/bronze_github.pull_requests.yaml b/src/ingestion/tests/e2e/metrics/schemas/bronze_github.pull_requests.yaml new file mode 100644 index 000000000..d45e3391c --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/schemas/bronze_github.pull_requests.yaml @@ -0,0 +1,31 @@ +schemas: + bronze_github.pull_requests: + $schema: http://json-schema.org/draft-07/schema# + type: object + additionalProperties: false + properties: + _airbyte_raw_id: { type: string } + _airbyte_extracted_at: { type: string, format: date-time } + _airbyte_meta: { type: string } + _airbyte_generation_id: { type: integer } + tenant_id: { type: string } + source_id: { type: string } + unique_key: { type: string } + repo_owner: { type: string } + repo_name: { type: string } + database_id: { type: integer } + number: { type: integer } + title: { type: string } + body: { type: string } + state: { type: string } + author_login: { type: string } + author_email: { type: string } + head_ref: { type: string } + base_ref: { type: string } + created_at: { type: string } + updated_at: { type: string } + closed_at: { type: string } + merge_commit_sha: { type: string } + changed_files: { type: integer } + additions: { type: integer } + deletions: { type: integer } diff --git a/src/ingestion/tests/e2e/metrics/schemas/bronze_jira.jira_user.yaml b/src/ingestion/tests/e2e/metrics/schemas/bronze_jira.jira_user.yaml index c5397f3c4..8238601da 100644 --- a/src/ingestion/tests/e2e/metrics/schemas/bronze_jira.jira_user.yaml +++ b/src/ingestion/tests/e2e/metrics/schemas/bronze_jira.jira_user.yaml @@ -9,6 +9,7 @@ schemas: properties: unique_key: { type: string } source_id: { type: string } + tenant_id: { type: [string, "null"] } account_id: { type: string } email: { type: [string, "null"] } display_name: { type: [string, "null"] } diff --git a/src/ingestion/tests/e2e/metrics/schemas/bronze_m365.sharepoint_activity.yaml b/src/ingestion/tests/e2e/metrics/schemas/bronze_m365.sharepoint_activity.yaml deleted file mode 100644 index de3b0f893..000000000 --- a/src/ingestion/tests/e2e/metrics/schemas/bronze_m365.sharepoint_activity.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# Reusable JSON schema for the table (all real fields). Resolved by table name. -# Mirrors the bronze_m365.sharepoint_activity placeholder DDL in -# src/ingestion/scripts/create-bronze-placeholders.sh (= onedrive + visitedPageCount). -schemas: - bronze_m365.sharepoint_activity: - $schema: http://json-schema.org/draft-07/schema# - type: object - additionalProperties: false - properties: - _airbyte_raw_id: { type: string } - _airbyte_extracted_at: { type: string, format: date-time } - _airbyte_meta: { type: string } - _airbyte_generation_id: { type: integer } - tenant_id: { type: [string, "null"] } - source_id: { type: [string, "null"] } - unique_key: { type: [string, "null"] } - userPrincipalName: { type: [string, "null"] } - reportRefreshDate: { type: [string, "null"] } - reportPeriod: { type: [string, "null"] } - lastActivityDate: { type: [string, "null"] } - viewedOrEditedFileCount: { type: [number, "null"] } - syncedFileCount: { type: [number, "null"] } - sharedInternallyFileCount: { type: [number, "null"] } - sharedExternallyFileCount: { type: [number, "null"] } - visitedPageCount: { type: [number, "null"] } diff --git a/src/ingestion/tests/e2e/metrics/schemas/bronze_confluence.wiki_footer_comments.yaml b/src/ingestion/tests/e2e/metrics/schemas/bronze_outline.wiki_page_versions.yaml similarity index 61% rename from src/ingestion/tests/e2e/metrics/schemas/bronze_confluence.wiki_footer_comments.yaml rename to src/ingestion/tests/e2e/metrics/schemas/bronze_outline.wiki_page_versions.yaml index ee5d5530e..38ad39245 100644 --- a/src/ingestion/tests/e2e/metrics/schemas/bronze_confluence.wiki_footer_comments.yaml +++ b/src/ingestion/tests/e2e/metrics/schemas/bronze_outline.wiki_page_versions.yaml @@ -1,7 +1,5 @@ -# Auto-generated JSON schema mirroring the connector bronze columns the dbt -# model reads for bronze_confluence.wiki_footer_comments (+ the 4 _airbyte_* CDK columns). schemas: - bronze_confluence.wiki_footer_comments: + bronze_outline.wiki_page_versions: $schema: http://json-schema.org/draft-07/schema# type: object additionalProperties: false @@ -14,8 +12,9 @@ schemas: tenant_id: { type: [string, "null"] } source_id: { type: [string, "null"] } page_id: { type: [string, "null"] } - comment_id: { type: [string, "null"] } - parent_comment_id: { type: [string, "null"] } + revision_id: { type: [string, "null"] } author_id: { type: [string, "null"] } + author_email: { type: [string, "null"] } created_at: { type: [string, "null"] } - resolution_status: { type: [string, "null"] } + message: { type: [string, "null"] } + collected_at: { type: [string, "null"] } diff --git a/src/ingestion/tests/e2e/metrics/schemas/bronze_zoom.meetings.yaml b/src/ingestion/tests/e2e/metrics/schemas/bronze_zoom.meetings.yaml deleted file mode 100644 index 36fe90e10..000000000 --- a/src/ingestion/tests/e2e/metrics/schemas/bronze_zoom.meetings.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# Reusable JSON schema for bronze_zoom.meetings (the columns zoom__meeting_sessions -# reads for session stitching). `id` is the reusable Zoom Meeting ID; `uuid` is the -# per-session UUID — consecutive sessions of the same `id` whose gap is ≤ 5 min are -# stitched into one logical meeting. `unique_key` is the connector-computed row key -# the table is ORDER BY'd on (distinct per seeded row). -schemas: - bronze_zoom.meetings: - $schema: http://json-schema.org/draft-07/schema# - type: object - additionalProperties: false - properties: - _airbyte_raw_id: { type: string } - _airbyte_extracted_at: { type: string, format: date-time } - _airbyte_meta: { type: string } - _airbyte_generation_id: { type: integer } - tenant_id: { type: string } - source_id: { type: string } - unique_key: { type: [string, "null"] } - id: { type: [string, "null"] } - uuid: { type: string } - start_time: { type: [string, "null"] } - end_time: { type: [string, "null"] } - has_video: { type: [boolean, "null"] } - has_screen_share: { type: [boolean, "null"] } diff --git a/src/ingestion/tests/e2e/metrics/schemas/bronze_zoom.participants.yaml b/src/ingestion/tests/e2e/metrics/schemas/bronze_zoom.participants.yaml deleted file mode 100644 index e027bdb33..000000000 --- a/src/ingestion/tests/e2e/metrics/schemas/bronze_zoom.participants.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# Reusable JSON schema for bronze_zoom.participants (the columns the zoom collab -# meeting models read). Bronze `unique_key` is the connector-computed row key the -# promoted table (and the placeholder) is ORDER BY'd on — give every distinct -# seeded row a distinct unique_key or RMT merges may collapse them; an intentional -# re-sync duplicate carries the SAME unique_key. Silver still derives its own -# MD5(tenant, source, lower(email), date-of-join_time) key and dedups participants -# via `LIMIT 1 BY (meeting_uuid, participant_uuid, join_time)`. -schemas: - bronze_zoom.participants: - $schema: http://json-schema.org/draft-07/schema# - type: object - additionalProperties: false - properties: - _airbyte_raw_id: { type: string } - _airbyte_extracted_at: { type: string, format: date-time } - _airbyte_meta: { type: string } - _airbyte_generation_id: { type: integer } - tenant_id: { type: string } - source_id: { type: string } - unique_key: { type: [string, "null"] } - email: { type: string } - user_name: { type: [string, "null"] } - meeting_uuid: { type: string } - participant_uuid: { type: string } - join_time: { type: string } - leave_time: { type: string } - camera: { type: [string, "null"] } - share_desktop: { type: [boolean, "null"] } - share_application: { type: [boolean, "null"] } - share_whiteboard: { type: [boolean, "null"] } - video_connection_type: { type: [string, "null"] } diff --git a/src/ingestion/tests/e2e/metrics/schemas/bronze_zulip_proxy.messages.yaml b/src/ingestion/tests/e2e/metrics/schemas/bronze_zulip_proxy.messages.yaml deleted file mode 100644 index bd2a24fd2..000000000 --- a/src/ingestion/tests/e2e/metrics/schemas/bronze_zulip_proxy.messages.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Reusable JSON schema for the table (all real placeholder columns). Resolved -# by table name. Mirrors bronze_zulip_proxy.messages in -# src/ingestion/scripts/create-bronze-placeholders.sh. -schemas: - bronze_zulip_proxy.messages: - $schema: http://json-schema.org/draft-07/schema# - type: object - additionalProperties: false - properties: - _airbyte_raw_id: { type: string } - _airbyte_extracted_at: { type: string, format: date-time } - _airbyte_meta: { type: string } - _airbyte_generation_id: { type: integer } - uniq: { type: string } - sender_id: { type: [number, "null"] } - count: { type: [number, "null"] } - created_at: { type: string } - tenant_id: { type: [string, "null"] } - source_id: { type: [string, "null"] } - unique_key: { type: string } diff --git a/src/ingestion/tests/e2e/metrics/schemas/bronze_zulip_proxy.users.yaml b/src/ingestion/tests/e2e/metrics/schemas/bronze_zulip_proxy.users.yaml deleted file mode 100644 index eb3995024..000000000 --- a/src/ingestion/tests/e2e/metrics/schemas/bronze_zulip_proxy.users.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# Reusable JSON schema for the table (all real placeholder columns). Resolved -# by table name. Mirrors bronze_zulip_proxy.users in -# src/ingestion/scripts/create-bronze-placeholders.sh. -schemas: - bronze_zulip_proxy.users: - $schema: http://json-schema.org/draft-07/schema# - type: object - additionalProperties: false - properties: - _airbyte_raw_id: { type: string } - _airbyte_extracted_at: { type: string, format: date-time } - _airbyte_meta: { type: string } - _airbyte_generation_id: { type: integer } - id: { type: [number, "null"] } - uuid: { type: [string, "null"] } - email: { type: [string, "null"] } - full_name: { type: [string, "null"] } - role: { type: [number, "null"] } - is_active: { type: [boolean, "null"] } - recipient_id: { type: [number, "null"] } - tenant_id: { type: [string, "null"] } - source_id: { type: [string, "null"] } - unique_key: { type: string } diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_bugs_to_task_ratio_ratiosum_jira.test.yaml b/src/ingestion/tests/e2e/metrics/task_delivery_bugs_to_task_ratio_ratiosum_jira.test.yaml deleted file mode 100644 index f98c0f704..000000000 --- a/src/ingestion/tests/e2e/metrics/task_delivery_bugs_to_task_ratio_ratiosum_jira.test.yaml +++ /dev/null @@ -1,153 +0,0 @@ -spec_version: 1 -description: > - Task Delivery - bugs_to_task_ratio - JIRA, RATIO-OF-SUMS correctness guard, - department-of-5 distribution + 6 stats in a single custom window. - - bronze -> silver -> gold: jira_issue + jira_issue_history (Status->Closed event = - the close date) + jira_user; issuetype lives in custom_fields_json. Per - (person, close-day): tasks_closed = count() of ALL closed issues, bugs_fixed = - countIf(issuetype='Bug'). The ...0011 query_ref aggregates over the PERIOD as a - RATIO-OF-SUMS: 100 * Sum(bugs_fixed) / Sum(tasks_closed) — NOT the average of the - daily ratios. - - This spec's point: erin has TWO close-days with UNEQUAL counts: - day A 2026-12-24: 4 closed, 1 Bug + 3 Task (daily ratio 25%) - day B 2026-12-25: 1 closed, 1 Bug (daily ratio 100%) - Ratio-of-sums = 100 * (1+1) / (4+1) = 100*2/5 = 40. The WRONG answer this guards - against is average-of-daily = (25+100)/2 = 62.5. - - Department of 5 (single close-day 2026-12-25 for the others): - alice: 4 closed, 0 bugs -> 0% - bob: 5 closed, 1 bug -> 20% - carol: 5 closed, 3 bugs -> 60% - dave: 5 closed, 4 bugs -> 80% - erin: ratio-of-sums -> 40% - Cohort sorted = {0,20,40,60,80}. The IC case requests erin (40): value=40, - median=40, range_min=0, range_max=80, p25=20, p75=60. range_max = plain cohort - max (not a P95 metric). Empty window 2026-06 -> no items. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_jira.jira_user: - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-alice-jira-acct, account_id: alice-jira-acct, email: alice@example.com, display_name: Alice Alpha } - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-bob-jira-acct, account_id: bob-jira-acct, email: bob@example.com, display_name: Bob Beta } - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-carol-jira-acct, account_id: carol-jira-acct, email: carol@example.com, display_name: Carol Gamma } - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-dave-jira-acct, account_id: dave-jira-acct, email: dave@example.com, display_name: Dave Delta } - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-erin-jira-acct, account_id: erin-jira-acct, email: erin@example.com, display_name: Erin Epsilon } - - bronze_jira.jira_statuses: - - $ref: templates/jira_task.yaml#/templates/status_open - - $ref: templates/jira_task.yaml#/templates/status_in_progress - - $ref: templates/jira_task.yaml#/templates/status_closed - - bronze_jira.jira_fields: - - $ref: templates/jira_task.yaml#/templates/field_status - - $ref: templates/jira_task.yaml#/templates/field_assignee - - $ref: templates/jira_task.yaml#/templates/field_issuetype - - bronze_jira.jira_issue: - # ── alice: 4 Task, 0 Bug ── - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSA-1, jira_id: RSA-1, unique_key: jira-test-RSA-1, id_readable: RSA1, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSA-2, jira_id: RSA-2, unique_key: jira-test-RSA-2, id_readable: RSA2, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSA-3, jira_id: RSA-3, unique_key: jira-test-RSA-3, id_readable: RSA3, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSA-4, jira_id: RSA-4, unique_key: jira-test-RSA-4, id_readable: RSA4, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' } - # ── bob: 1 Bug + 4 Task ── - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSB-1, jira_id: RSB-1, unique_key: jira-test-RSB-1, id_readable: RSB1, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10004","name":"Bug"},"assignee":{"accountId":"bob-jira-acct","displayName":"Bob Beta"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSB-2, jira_id: RSB-2, unique_key: jira-test-RSB-2, id_readable: RSB2, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"bob-jira-acct","displayName":"Bob Beta"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSB-3, jira_id: RSB-3, unique_key: jira-test-RSB-3, id_readable: RSB3, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"bob-jira-acct","displayName":"Bob Beta"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSB-4, jira_id: RSB-4, unique_key: jira-test-RSB-4, id_readable: RSB4, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"bob-jira-acct","displayName":"Bob Beta"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSB-5, jira_id: RSB-5, unique_key: jira-test-RSB-5, id_readable: RSB5, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"bob-jira-acct","displayName":"Bob Beta"}}' } - # ── carol: 3 Bug + 2 Task ── - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSC-1, jira_id: RSC-1, unique_key: jira-test-RSC-1, id_readable: RSC1, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10004","name":"Bug"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSC-2, jira_id: RSC-2, unique_key: jira-test-RSC-2, id_readable: RSC2, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10004","name":"Bug"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSC-3, jira_id: RSC-3, unique_key: jira-test-RSC-3, id_readable: RSC3, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10004","name":"Bug"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSC-4, jira_id: RSC-4, unique_key: jira-test-RSC-4, id_readable: RSC4, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSC-5, jira_id: RSC-5, unique_key: jira-test-RSC-5, id_readable: RSC5, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' } - # ── dave: 4 Bug + 1 Task ── - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSD-1, jira_id: RSD-1, unique_key: jira-test-RSD-1, id_readable: RSD1, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10004","name":"Bug"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSD-2, jira_id: RSD-2, unique_key: jira-test-RSD-2, id_readable: RSD2, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10004","name":"Bug"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSD-3, jira_id: RSD-3, unique_key: jira-test-RSD-3, id_readable: RSD3, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10004","name":"Bug"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSD-4, jira_id: RSD-4, unique_key: jira-test-RSD-4, id_readable: RSD4, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10004","name":"Bug"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSD-5, jira_id: RSD-5, unique_key: jira-test-RSD-5, id_readable: RSD5, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' } - # ── erin: day A (12-24) 1 Bug + 3 Task; day B (12-25) 1 Bug ── - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSE-1, jira_id: RSE-1, unique_key: jira-test-RSE-1, id_readable: RSE1, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10004","name":"Bug"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSE-2, jira_id: RSE-2, unique_key: jira-test-RSE-2, id_readable: RSE2, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSE-3, jira_id: RSE-3, unique_key: jira-test-RSE-3, id_readable: RSE3, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSE-4, jira_id: RSE-4, unique_key: jira-test-RSE-4, id_readable: RSE4, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RSE-5, jira_id: RSE-5, unique_key: jira-test-RSE-5, id_readable: RSE5, created: "2026-12-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10004","name":"Bug"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' } - - bronze_jira.jira_issue_history: - # alice: all close 2026-12-25. - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSA1, changelog_id: "60001", unique_key: jira-test-RSA1-60001, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: alice-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSA2, changelog_id: "60002", unique_key: jira-test-RSA2-60002, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: alice-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSA3, changelog_id: "60003", unique_key: jira-test-RSA3-60003, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: alice-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSA4, changelog_id: "60004", unique_key: jira-test-RSA4-60004, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: alice-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - # bob: all close 2026-12-25. - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSB1, changelog_id: "60005", unique_key: jira-test-RSB1-60005, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: bob-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSB2, changelog_id: "60006", unique_key: jira-test-RSB2-60006, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: bob-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSB3, changelog_id: "60007", unique_key: jira-test-RSB3-60007, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: bob-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSB4, changelog_id: "60008", unique_key: jira-test-RSB4-60008, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: bob-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSB5, changelog_id: "60009", unique_key: jira-test-RSB5-60009, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: bob-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - # carol: all close 2026-12-25. - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSC1, changelog_id: "60010", unique_key: jira-test-RSC1-60010, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: carol-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSC2, changelog_id: "60011", unique_key: jira-test-RSC2-60011, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: carol-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSC3, changelog_id: "60012", unique_key: jira-test-RSC3-60012, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: carol-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSC4, changelog_id: "60013", unique_key: jira-test-RSC4-60013, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: carol-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSC5, changelog_id: "60014", unique_key: jira-test-RSC5-60014, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: carol-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - # dave: all close 2026-12-25. - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSD1, changelog_id: "60015", unique_key: jira-test-RSD1-60015, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: dave-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSD2, changelog_id: "60016", unique_key: jira-test-RSD2-60016, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: dave-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSD3, changelog_id: "60017", unique_key: jira-test-RSD3-60017, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: dave-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSD4, changelog_id: "60018", unique_key: jira-test-RSD4-60018, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: dave-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSD5, changelog_id: "60019", unique_key: jira-test-RSD5-60019, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: dave-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - # erin day A: RSE-1..4 close 2026-12-24. - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSE1, changelog_id: "60020", unique_key: jira-test-RSE1-60020, created_at: "2026-12-24T09:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSE2, changelog_id: "60021", unique_key: jira-test-RSE2-60021, created_at: "2026-12-24T09:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSE3, changelog_id: "60022", unique_key: jira-test-RSE3-60022, created_at: "2026-12-24T09:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSE4, changelog_id: "60023", unique_key: jira-test-RSE4-60023, created_at: "2026-12-24T09:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - # erin day B: RSE-5 close 2026-12-25. - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RSE5, changelog_id: "60024", unique_key: jira-test-RSE5-60024, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - -cases: - - name: "bugs_to_task_ratio (ratio-of-sums) — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-20' and metric_date le '2026-12-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: bugs_to_task_ratio } - equal: { value: 40, median: 40, range_min: 0, range_max: 80, p25: 20, p75: 60 } - - - name: "bugs_to_task_ratio (ratio-of-sums) — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_status_category_jira.test.yaml b/src/ingestion/tests/e2e/metrics/task_delivery_status_category_jira.test.yaml deleted file mode 100644 index 66c97772d..000000000 --- a/src/ingestion/tests/e2e/metrics/task_delivery_status_category_jira.test.yaml +++ /dev/null @@ -1,131 +0,0 @@ -spec_version: 1 -description: > - Task Delivery - tasks_completed - JIRA - close-detection by statusCategory, - NOT by status display name (issue #1541). - - Regression guard for the fix that replaced the hardcoded - `status_name IN ('Closed','Resolved','Verified')` with a join to - silver.class_task_statuses on status_category = 'done'. - - Alice closes THREE issues on 2026-12-25, each ending in a different status: - A → "Done" (statusCategory done, id 100) → COUNTED - B → "Готово" (statusCategory done, id 101) → COUNTED (non-English name) - C → "Closed" (statusCategory indeterminate, id 103) → NOT counted - - Expected tasks_completed = 2 (A + B). - * The OLD name-based logic would score 1 — it matches only "Closed" (C) and - misses "Done"/"Готово". So this fixture FAILS on the pre-#1541 code. - * A naive "count every transition" would score 3. - Only category-based detection yields 2, proving closedness is driven by - statusCategory and is locale- / display-name-independent. - - The custom window 2026-12-20..12-31 captures all three close events. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - bronze_jira.jira_user: - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-alice-jira-acct, account_id: alice-jira-acct, email: alice@example.com, display_name: Alice Alpha } - - # Status lookup — the crux. "Done"/"Готово" carry statusCategory done; the - # decoy "Closed" carries indeterminate (id 4) so a name match would wrongly - # count it. In Progress (id 3) is the pre-transition status for all three. - bronze_jira.jira_statuses: - - { $ref: templates/jira_task.yaml#/templates/status_in_progress } - - { $ref: templates/jira_task.yaml#/templates/jira_status, unique_key: jira-test-status-100, status_id: "100", name: Done, category_id: 3, category_name: Done, category_key: done } - - { $ref: templates/jira_task.yaml#/templates/jira_status, unique_key: jira-test-status-101, status_id: "101", name: "Готово", category_id: 3, category_name: Done, category_key: done } - - { $ref: templates/jira_task.yaml#/templates/jira_status, unique_key: jira-test-status-103, status_id: "103", name: Closed, category_id: 4, category_name: "In Progress", category_key: indeterminate } - - bronze_jira.jira_fields: - - $ref: templates/jira_task.yaml#/templates/field_status - - $ref: templates/jira_task.yaml#/templates/field_assignee - - $ref: templates/jira_task.yaml#/templates/field_issuetype - - bronze_jira.jira_issue: - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: SC-A - jira_id: SC-A - unique_key: jira-test-SC-A - id_readable: SCA - created: "2026-12-01T09:00:00" - custom_fields_json: '{"status":{"id":"100","name":"Done"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: SC-B - jira_id: SC-B - unique_key: jira-test-SC-B - id_readable: SCB - created: "2026-12-01T09:00:00" - custom_fields_json: '{"status":{"id":"101","name":"Готово"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: SC-C - jira_id: SC-C - unique_key: jira-test-SC-C - id_readable: SCC - created: "2026-12-01T09:00:00" - custom_fields_json: '{"status":{"id":"103","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' - - bronze_jira.jira_issue_history: - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: SCA - changelog_id: "60000" - unique_key: jira-test-SCA-60000 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: alice-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Done","to":"100"}]' - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: SCB - changelog_id: "60001" - unique_key: jira-test-SCB-60001 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: alice-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Готово","to":"101"}]' - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: SCC - changelog_id: "60002" - unique_key: jira-test-SCC-60002 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: alice-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"103"}]' - -cases: - - name: "tasks_completed counts done-category statuses regardless of name (#1541)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-12-20' and metric_date le '2026-12-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - # Done + Готово (statusCategory done) counted; "Closed" (indeterminate) excluded. - # Department = alice alone, so the distribution is the single value {2} and - # every bullet stat collapses to 2 (the no-unasserted-stat gate requires all - # returned stats to be checked). - - in: task_delivery - find: { metric_key: tasks_completed } - equal: { value: 2, median: 2, p25: 2, p75: 2, range_min: 2, range_max: 2 } - - - name: "the indeterminate decoy alone yields no completion (empty window before close)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'alice@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_task_reopen_rate_gate_jira.test.yaml b/src/ingestion/tests/e2e/metrics/task_delivery_task_reopen_rate_gate_jira.test.yaml deleted file mode 100644 index 3db6e71a1..000000000 --- a/src/ingestion/tests/e2e/metrics/task_delivery_task_reopen_rate_gate_jira.test.yaml +++ /dev/null @@ -1,105 +0,0 @@ -spec_version: 1 -description: > - Task Delivery - task_reopen_rate - JIRA, the <5-closes GATE branch (NULL), - department-of-5 distribution + 6 stats in a single custom window. - - bronze -> silver -> gold: jira_issue (Closed) + jira_issue_history (close -> - reopen -> close) + jira_user; bamboohr employees give the Engineering - org_unit_id (the cohort). task_reopen_rate = 100 * reopens / closes, but it is - SUPPRESSED to NULL unless the person has >= 5 closes in the window (small-sample - gate). Here every member gets a single close-reopen-close issue = exactly 2 closes - and 1 reopen (2 < 5) -> rate NULL for ALL, so the cohort row is fully null. - - Department: 5 Engineering members alice..erin, each with ONE issue (snapshot - Closed) whose changelog is In Progress->Closed (12-22), Closed->In Progress - (12-24), In Progress->Closed (12-26). The IC case requests erin and asserts every - stat is null. Empty window 2026-06 -> no items. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_jira.jira_user: - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-alice-jira-acct, account_id: alice-jira-acct, email: alice@example.com, display_name: Alice Alpha } - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-bob-jira-acct, account_id: bob-jira-acct, email: bob@example.com, display_name: Bob Beta } - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-carol-jira-acct, account_id: carol-jira-acct, email: carol@example.com, display_name: Carol Gamma } - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-dave-jira-acct, account_id: dave-jira-acct, email: dave@example.com, display_name: Dave Delta } - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-erin-jira-acct, account_id: erin-jira-acct, email: erin@example.com, display_name: Erin Epsilon } - - bronze_jira.jira_statuses: - - $ref: templates/jira_task.yaml#/templates/status_open - - $ref: templates/jira_task.yaml#/templates/status_in_progress - - $ref: templates/jira_task.yaml#/templates/status_closed - - bronze_jira.jira_fields: - - $ref: templates/jira_task.yaml#/templates/field_status - - $ref: templates/jira_task.yaml#/templates/field_assignee - - $ref: templates/jira_task.yaml#/templates/field_issuetype - - bronze_jira.jira_issue: - # Each member: ONE issue, snapshot Closed. - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RGA-1, jira_id: RGA-1, unique_key: jira-test-RGA-1, id_readable: RGA1, created: "2026-03-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RGB-1, jira_id: RGB-1, unique_key: jira-test-RGB-1, id_readable: RGB1, created: "2026-03-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"bob-jira-acct","displayName":"Bob Beta"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RGC-1, jira_id: RGC-1, unique_key: jira-test-RGC-1, id_readable: RGC1, created: "2026-03-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RGD-1, jira_id: RGD-1, unique_key: jira-test-RGD-1, id_readable: RGD1, created: "2026-03-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' } - - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: RGE-1, jira_id: RGE-1, unique_key: jira-test-RGE-1, id_readable: RGE1, created: "2026-03-20T09:00:00", custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' } - - bronze_jira.jira_issue_history: - # Per member: close (12-22) -> reopen (12-24) -> close (12-26) = 2 closes, 1 reopen (< 5-close gate). - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGA1, changelog_id: "58001", unique_key: jira-test-RGA1-58001, created_at: "2026-03-22T09:00:00.000+0000", author_account_id: alice-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGA1, changelog_id: "58002", unique_key: jira-test-RGA1-58002, created_at: "2026-03-24T09:00:00.000+0000", author_account_id: alice-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"Closed","from":"6","toString":"In Progress","to":"3"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGA1, changelog_id: "58003", unique_key: jira-test-RGA1-58003, created_at: "2026-03-26T09:00:00.000+0000", author_account_id: alice-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGB1, changelog_id: "58004", unique_key: jira-test-RGB1-58004, created_at: "2026-03-22T09:00:00.000+0000", author_account_id: bob-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGB1, changelog_id: "58005", unique_key: jira-test-RGB1-58005, created_at: "2026-03-24T09:00:00.000+0000", author_account_id: bob-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"Closed","from":"6","toString":"In Progress","to":"3"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGB1, changelog_id: "58006", unique_key: jira-test-RGB1-58006, created_at: "2026-03-26T09:00:00.000+0000", author_account_id: bob-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGC1, changelog_id: "58007", unique_key: jira-test-RGC1-58007, created_at: "2026-03-22T09:00:00.000+0000", author_account_id: carol-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGC1, changelog_id: "58008", unique_key: jira-test-RGC1-58008, created_at: "2026-03-24T09:00:00.000+0000", author_account_id: carol-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"Closed","from":"6","toString":"In Progress","to":"3"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGC1, changelog_id: "58009", unique_key: jira-test-RGC1-58009, created_at: "2026-03-26T09:00:00.000+0000", author_account_id: carol-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGD1, changelog_id: "58010", unique_key: jira-test-RGD1-58010, created_at: "2026-03-22T09:00:00.000+0000", author_account_id: dave-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGD1, changelog_id: "58011", unique_key: jira-test-RGD1-58011, created_at: "2026-03-24T09:00:00.000+0000", author_account_id: dave-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"Closed","from":"6","toString":"In Progress","to":"3"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGD1, changelog_id: "58012", unique_key: jira-test-RGD1-58012, created_at: "2026-03-26T09:00:00.000+0000", author_account_id: dave-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGE1, changelog_id: "58013", unique_key: jira-test-RGE1-58013, created_at: "2026-03-22T09:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGE1, changelog_id: "58014", unique_key: jira-test-RGE1-58014, created_at: "2026-03-24T09:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"Closed","from":"6","toString":"In Progress","to":"3"}]' } - - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: RGE1, changelog_id: "58015", unique_key: jira-test-RGE1-58015, created_at: "2026-03-26T09:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } - -cases: - - name: "task_reopen_rate (gate) — custom scope (department of 5, all < 5 closes → NULL)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-03-20' and metric_date le '2026-03-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: task_reopen_rate } - equal: { value: null, median: null, range_min: null, range_max: null, p25: null, p75: null } - - - name: "task_reopen_rate (gate) — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_bugs_to_task_ratio_jira.test.yaml b/src/ingestion/tests/e2e/metrics/tasks_bugs.test.yaml similarity index 87% rename from src/ingestion/tests/e2e/metrics/task_delivery_bugs_to_task_ratio_jira.test.yaml rename to src/ingestion/tests/e2e/metrics/tasks_bugs.test.yaml index dd223d4a6..7157cf452 100644 --- a/src/ingestion/tests/e2e/metrics/task_delivery_bugs_to_task_ratio_jira.test.yaml +++ b/src/ingestion/tests/e2e/metrics/tasks_bugs.test.yaml @@ -13,7 +13,7 @@ description: > numerator and denominator. Department: 5 Engineering members alice..erin of rank r in 1..5. Each closes - EXACTLY 4 issues on the same close date (2026-12-25): (r-1) Bugs + (5-r) Tasks + EXACTLY 4 issues on the same close date (2026-06-25): (r-1) Bugs + (5-r) Tasks -> per-person ratio = 100*(r-1)/4 = {0,25,50,75,100}. alice(r=1)=0% (0 bugs / 4 closed, denominator 4>0 so NOT dropped); erin(r=5)=100%. value = the requested member's OWN ratio; median/p25/p75/range = the department distribution. @@ -198,28 +198,28 @@ bronze: id_readable: BAW1 changelog_id: "50000" unique_key: jira-test-BAW1-50000 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: alice-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BAW2 changelog_id: "50001" unique_key: jira-test-BAW2-50001 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: alice-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BAW3 changelog_id: "50002" unique_key: jira-test-BAW3-50002 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: alice-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BAW4 changelog_id: "50003" unique_key: jira-test-BAW4-50003 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: alice-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' # ── Bob Beta (rank 2) ── @@ -227,28 +227,28 @@ bronze: id_readable: BBW1 changelog_id: "50004" unique_key: jira-test-BBW1-50004 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: bob-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BBW2 changelog_id: "50005" unique_key: jira-test-BBW2-50005 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: bob-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BBW3 changelog_id: "50006" unique_key: jira-test-BBW3-50006 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: bob-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BBW4 changelog_id: "50007" unique_key: jira-test-BBW4-50007 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: bob-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' # ── Carol Gamma (rank 3) ── @@ -256,28 +256,28 @@ bronze: id_readable: BCW1 changelog_id: "50008" unique_key: jira-test-BCW1-50008 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: carol-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BCW2 changelog_id: "50009" unique_key: jira-test-BCW2-50009 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: carol-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BCW3 changelog_id: "50010" unique_key: jira-test-BCW3-50010 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: carol-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BCW4 changelog_id: "50011" unique_key: jira-test-BCW4-50011 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: carol-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' # ── Dave Delta (rank 4) ── @@ -285,28 +285,28 @@ bronze: id_readable: BDW1 changelog_id: "50012" unique_key: jira-test-BDW1-50012 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: dave-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BDW2 changelog_id: "50013" unique_key: jira-test-BDW2-50013 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: dave-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BDW3 changelog_id: "50014" unique_key: jira-test-BDW3-50014 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: dave-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BDW4 changelog_id: "50015" unique_key: jira-test-BDW4-50015 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: dave-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' # ── Erin Epsilon (rank 5) ── @@ -314,65 +314,67 @@ bronze: id_readable: BEW1 changelog_id: "50016" unique_key: jira-test-BEW1-50016 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: erin-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BEW2 changelog_id: "50017" unique_key: jira-test-BEW2-50017 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: erin-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BEW3 changelog_id: "50018" unique_key: jira-test-BEW3-50018 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: erin-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: BEW4 changelog_id: "50019" unique_key: jira-test-BEW4-50019 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: erin-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' cases: - - name: "bugs_to_task_ratio — custom scope (department of 5)" + - name: Unified task bug metrics request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-20' and metric_date le '2026-12-31'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-06-20", to: "2026-06-30" } + metrics: + - metric_key: tasks.bugs_fixed + views: &bug_views + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { metric_key: tasks.bugs_ratio, views: *bug_views } expect: - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: bugs_to_task_ratio } - equal: { value: 100, median: 50, range_min: 0, range_max: 100, p25: 25, p75: 75 } - - - name: "bugs_to_task_ratio — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" + - metric: tasks.bugs_fixed + view: period + find: { entity_id: erin@example.com } + equal: { value: 4 } + - metric: tasks.bugs_fixed + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 4, p25: null, median: null, p75: null, min: null, max: null, n: 4 } + - metric: tasks.bugs_fixed + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 4.0))" + - metric: tasks.bugs_ratio + view: period + find: { entity_id: erin@example.com } + equal: { value: 100 } + - metric: tasks.bugs_ratio + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 100, p25: null, median: null, p75: null, min: null, max: null, n: 4 } + - metric: tasks.bugs_ratio + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 100.0))" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_tasks_completed_jira.test.yaml b/src/ingestion/tests/e2e/metrics/tasks_closed.test.yaml similarity index 92% rename from src/ingestion/tests/e2e/metrics/task_delivery_tasks_completed_jira.test.yaml rename to src/ingestion/tests/e2e/metrics/tasks_closed.test.yaml index f5d6f1d13..6b04dec20 100644 --- a/src/ingestion/tests/e2e/metrics/task_delivery_tasks_completed_jira.test.yaml +++ b/src/ingestion/tests/e2e/metrics/tasks_closed.test.yaml @@ -266,39 +266,29 @@ bronze: items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' cases: - - name: "tasks_completed — custom scope (department of 5)" + - name: tasks.closed request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-20' and metric_date le '2026-12-31'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-20", to: "2026-12-31" } + metrics: + - metric_key: tasks.closed + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } expect: - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: tasks_completed } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - - name: "tasks_completed — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" + - metric: tasks.closed + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: tasks.closed + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: tasks.closed + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 5.0))" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_task_dev_time_jira.test.yaml b/src/ingestion/tests/e2e/metrics/tasks_dev_time.test.yaml similarity index 84% rename from src/ingestion/tests/e2e/metrics/task_delivery_task_dev_time_jira.test.yaml rename to src/ingestion/tests/e2e/metrics/tasks_dev_time.test.yaml index b7c5953af..405c54e71 100644 --- a/src/ingestion/tests/e2e/metrics/task_delivery_task_dev_time_jira.test.yaml +++ b/src/ingestion/tests/e2e/metrics/tasks_dev_time.test.yaml @@ -55,38 +55,33 @@ bronze: - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: DVE1, changelog_id: "51005", unique_key: jira-test-DVE1-51005, created_at: "2026-03-25T05:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } cases: - - name: "task_dev_time — custom scope (department of 5)" + - name: tasks.dev_time request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-03-20' and metric_date le '2026-03-31'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-03-20", to: "2026-03-31" } + metrics: + - metric_key: tasks.dev_time + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: histogram } expect: - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: task_dev_time } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - name: "task_dev_time — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" + - metric: tasks.dev_time + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: tasks.dev_time + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: tasks.dev_time + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 5.0))" + - metric: tasks.dev_time + view: histogram + assert: "items.exists(h, h.entity_id == 'erin@example.com' && size(h.bins) > 0)" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_due_date_compliance_jira.test.yaml b/src/ingestion/tests/e2e/metrics/tasks_due_dates.test.yaml similarity index 81% rename from src/ingestion/tests/e2e/metrics/task_delivery_due_date_compliance_jira.test.yaml rename to src/ingestion/tests/e2e/metrics/tasks_due_dates.test.yaml index b4dca3bb9..050d59812 100644 --- a/src/ingestion/tests/e2e/metrics/task_delivery_due_date_compliance_jira.test.yaml +++ b/src/ingestion/tests/e2e/metrics/tasks_due_dates.test.yaml @@ -1,6 +1,6 @@ spec_version: 1 description: > - Task Delivery - due_date_compliance - JIRA, IC Bullet Task Delivery (...0011), + Unified task due-date metrics, department-of-5 distribution + 6 stats in a single custom window. bronze -> silver -> gold: jira_issue + jira_issue_history (Status->Closed event @@ -10,7 +10,7 @@ description: > The ...0011 query_ref: if(Σwith_due>0, round(100*Σon_time/Σwith_due, 1)). Department: 5 Engineering members alice..erin of rank r in 1..5. Each closes - EXACTLY 4 issues on the same close date (2026-12-25), ALL carrying a due date: + EXACTLY 4 issues on the same close date (2026-06-25), ALL carrying a due date: (r-1) on time (due = close+5d) + (5-r) late (due = close-5d) -> per-person compliance = 100*(r-1)/4 = {0,25,50,75,100}. alice(r=1)=0% (0 on-time / 4 with due, denominator>0 so NOT dropped); erin(r=5)=100%. value = the requested @@ -55,7 +55,7 @@ bronze: unique_key: jira-test-DAW-1 id_readable: DAW1 created: "2026-01-05T09:00:00" - due_date: "2026-12-20" + due_date: "2026-06-20" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DAW-2 @@ -63,7 +63,7 @@ bronze: unique_key: jira-test-DAW-2 id_readable: DAW2 created: "2026-01-05T09:00:00" - due_date: "2026-12-20" + due_date: "2026-06-20" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DAW-3 @@ -71,7 +71,7 @@ bronze: unique_key: jira-test-DAW-3 id_readable: DAW3 created: "2026-01-05T09:00:00" - due_date: "2026-12-20" + due_date: "2026-06-20" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DAW-4 @@ -79,7 +79,7 @@ bronze: unique_key: jira-test-DAW-4 id_readable: DAW4 created: "2026-01-05T09:00:00" - due_date: "2026-12-20" + due_date: "2026-06-20" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' # ── Bob Beta (rank 2) ── - $ref: templates/jira_task.yaml#/templates/jira_issue @@ -88,7 +88,7 @@ bronze: unique_key: jira-test-DBW-1 id_readable: DBW1 created: "2026-01-05T09:00:00" - due_date: "2026-12-30" + due_date: "2026-06-30" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"bob-jira-acct","displayName":"Bob Beta"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DBW-2 @@ -96,7 +96,7 @@ bronze: unique_key: jira-test-DBW-2 id_readable: DBW2 created: "2026-01-05T09:00:00" - due_date: "2026-12-20" + due_date: "2026-06-20" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"bob-jira-acct","displayName":"Bob Beta"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DBW-3 @@ -104,7 +104,7 @@ bronze: unique_key: jira-test-DBW-3 id_readable: DBW3 created: "2026-01-05T09:00:00" - due_date: "2026-12-20" + due_date: "2026-06-20" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"bob-jira-acct","displayName":"Bob Beta"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DBW-4 @@ -112,7 +112,7 @@ bronze: unique_key: jira-test-DBW-4 id_readable: DBW4 created: "2026-01-05T09:00:00" - due_date: "2026-12-20" + due_date: "2026-06-20" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"bob-jira-acct","displayName":"Bob Beta"}}' # ── Carol Gamma (rank 3) ── - $ref: templates/jira_task.yaml#/templates/jira_issue @@ -121,7 +121,7 @@ bronze: unique_key: jira-test-DCW-1 id_readable: DCW1 created: "2026-01-05T09:00:00" - due_date: "2026-12-30" + due_date: "2026-06-30" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DCW-2 @@ -129,7 +129,7 @@ bronze: unique_key: jira-test-DCW-2 id_readable: DCW2 created: "2026-01-05T09:00:00" - due_date: "2026-12-30" + due_date: "2026-06-30" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DCW-3 @@ -137,7 +137,7 @@ bronze: unique_key: jira-test-DCW-3 id_readable: DCW3 created: "2026-01-05T09:00:00" - due_date: "2026-12-20" + due_date: "2026-06-20" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DCW-4 @@ -145,7 +145,7 @@ bronze: unique_key: jira-test-DCW-4 id_readable: DCW4 created: "2026-01-05T09:00:00" - due_date: "2026-12-20" + due_date: "2026-06-20" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' # ── Dave Delta (rank 4) ── - $ref: templates/jira_task.yaml#/templates/jira_issue @@ -154,7 +154,7 @@ bronze: unique_key: jira-test-DDW-1 id_readable: DDW1 created: "2026-01-05T09:00:00" - due_date: "2026-12-30" + due_date: "2026-06-30" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DDW-2 @@ -162,7 +162,7 @@ bronze: unique_key: jira-test-DDW-2 id_readable: DDW2 created: "2026-01-05T09:00:00" - due_date: "2026-12-30" + due_date: "2026-06-30" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DDW-3 @@ -170,7 +170,7 @@ bronze: unique_key: jira-test-DDW-3 id_readable: DDW3 created: "2026-01-05T09:00:00" - due_date: "2026-12-30" + due_date: "2026-06-30" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DDW-4 @@ -178,7 +178,7 @@ bronze: unique_key: jira-test-DDW-4 id_readable: DDW4 created: "2026-01-05T09:00:00" - due_date: "2026-12-20" + due_date: "2026-06-20" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' # ── Erin Epsilon (rank 5) ── - $ref: templates/jira_task.yaml#/templates/jira_issue @@ -187,7 +187,7 @@ bronze: unique_key: jira-test-DEW-1 id_readable: DEW1 created: "2026-01-05T09:00:00" - due_date: "2026-12-30" + due_date: "2026-06-30" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DEW-2 @@ -195,7 +195,7 @@ bronze: unique_key: jira-test-DEW-2 id_readable: DEW2 created: "2026-01-05T09:00:00" - due_date: "2026-12-30" + due_date: "2026-06-30" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DEW-3 @@ -203,7 +203,7 @@ bronze: unique_key: jira-test-DEW-3 id_readable: DEW3 created: "2026-01-05T09:00:00" - due_date: "2026-12-30" + due_date: "2026-06-30" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' - $ref: templates/jira_task.yaml#/templates/jira_issue id: DEW-4 @@ -211,7 +211,7 @@ bronze: unique_key: jira-test-DEW-4 id_readable: DEW4 created: "2026-01-05T09:00:00" - due_date: "2026-12-30" + due_date: "2026-06-20" custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' bronze_jira.jira_issue_history: @@ -220,28 +220,28 @@ bronze: id_readable: DAW1 changelog_id: "50000" unique_key: jira-test-DAW1-50000 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: alice-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DAW2 changelog_id: "50001" unique_key: jira-test-DAW2-50001 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: alice-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DAW3 changelog_id: "50002" unique_key: jira-test-DAW3-50002 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: alice-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DAW4 changelog_id: "50003" unique_key: jira-test-DAW4-50003 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: alice-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' # ── Bob Beta (rank 2) ── @@ -249,28 +249,28 @@ bronze: id_readable: DBW1 changelog_id: "50004" unique_key: jira-test-DBW1-50004 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: bob-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DBW2 changelog_id: "50005" unique_key: jira-test-DBW2-50005 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: bob-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DBW3 changelog_id: "50006" unique_key: jira-test-DBW3-50006 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: bob-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DBW4 changelog_id: "50007" unique_key: jira-test-DBW4-50007 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: bob-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' # ── Carol Gamma (rank 3) ── @@ -278,28 +278,28 @@ bronze: id_readable: DCW1 changelog_id: "50008" unique_key: jira-test-DCW1-50008 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: carol-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DCW2 changelog_id: "50009" unique_key: jira-test-DCW2-50009 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: carol-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DCW3 changelog_id: "50010" unique_key: jira-test-DCW3-50010 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: carol-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DCW4 changelog_id: "50011" unique_key: jira-test-DCW4-50011 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: carol-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' # ── Dave Delta (rank 4) ── @@ -307,28 +307,28 @@ bronze: id_readable: DDW1 changelog_id: "50012" unique_key: jira-test-DDW1-50012 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: dave-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DDW2 changelog_id: "50013" unique_key: jira-test-DDW2-50013 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: dave-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DDW3 changelog_id: "50014" unique_key: jira-test-DDW3-50014 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: dave-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DDW4 changelog_id: "50015" unique_key: jira-test-DDW4-50015 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: dave-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' # ── Erin Epsilon (rank 5) ── @@ -336,65 +336,83 @@ bronze: id_readable: DEW1 changelog_id: "50016" unique_key: jira-test-DEW1-50016 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: erin-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DEW2 changelog_id: "50017" unique_key: jira-test-DEW2-50017 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: erin-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DEW3 changelog_id: "50018" unique_key: jira-test-DEW3-50018 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: erin-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - $ref: templates/jira_task.yaml#/templates/jira_history id_readable: DEW4 changelog_id: "50019" unique_key: jira-test-DEW4-50019 - created_at: "2026-12-25T14:00:00.000+0000" + created_at: "2026-06-25T14:00:00.000+0000" author_account_id: erin-jira-acct items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' cases: - - name: "due_date_compliance — custom scope (department of 5)" + - name: Unified task due-date metrics request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-20' and metric_date le '2026-12-31'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-06-20", to: "2026-06-30" } + metrics: + - metric_key: tasks.due_date_compliance + views: &task_views + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { metric_key: tasks.on_time_delivery, views: *task_views } + - metric_key: tasks.avg_slip + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } expect: - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: due_date_compliance } - equal: { value: 100, median: 50, range_min: 0, range_max: 100, p25: 25, p75: 75 } - - - name: "due_date_compliance — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" + - metric: tasks.due_date_compliance + view: period + find: &erin { entity_id: erin@example.com } + equal: { value: 75 } + - metric: tasks.due_date_compliance + view: peer + find: *erin + equal: { target_value: 75, p25: null, median: null, p75: null, min: null, max: null, n: 4 } + - metric: tasks.due_date_compliance + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-06-25' && double(p.value) == 75.0))" + - metric: tasks.on_time_delivery + view: period + find: *erin + equal: { value: 75 } + - metric: tasks.on_time_delivery + view: peer + find: *erin + equal: { target_value: 75, p25: null, median: null, p75: null, min: null, max: null, n: 4 } + - metric: tasks.on_time_delivery + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-06-25' && double(p.value) == 75.0))" + - metric: tasks.avg_slip + view: period + find: *erin + equal: { value: 5 } + - metric: tasks.avg_slip + view: peer + find: *erin + equal: { target_value: 5, p25: 5, median: 5, p75: 5, min: 5, max: 5, n: 5 } + - metric: tasks.avg_slip + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-06-25' && double(p.value) == 5.0))" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_estimation_accuracy_jira.test.yaml b/src/ingestion/tests/e2e/metrics/tasks_estimation_accuracy.test.yaml similarity index 86% rename from src/ingestion/tests/e2e/metrics/task_delivery_estimation_accuracy_jira.test.yaml rename to src/ingestion/tests/e2e/metrics/tasks_estimation_accuracy.test.yaml index 740b7bd43..c8b16c108 100644 --- a/src/ingestion/tests/e2e/metrics/task_delivery_estimation_accuracy_jira.test.yaml +++ b/src/ingestion/tests/e2e/metrics/tasks_estimation_accuracy.test.yaml @@ -62,39 +62,29 @@ bronze: - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: EAE1, changelog_id: "55005", unique_key: jira-test-EAE1-55005, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"},{"field":"timeoriginalestimate","fieldId":"timeoriginalestimate","from":null,"fromString":null,"to":"36000","toString":"36000"},{"field":"timespent","fieldId":"timespent","from":null,"fromString":null,"to":"36000","toString":"36000"}]' } cases: - - name: "estimation_accuracy — custom scope (department of 5)" + - name: tasks.estimation_accuracy request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-20' and metric_date le '2026-12-31'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-20", to: "2026-12-31" } + metrics: + - metric_key: tasks.estimation_accuracy + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } expect: - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: estimation_accuracy } - equal: { value: 100, median: 80, range_min: 60, range_max: 100, p25: 70, p75: 90 } - - - name: "estimation_accuracy — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" + - metric: tasks.estimation_accuracy + view: period + find: { entity_id: erin@example.com } + equal: { value: 100 } + - metric: tasks.estimation_accuracy + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 100, p25: 70, median: 80, p75: 90, min: 60, max: 100, n: 5 } + - metric: tasks.estimation_accuracy + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 100.0))" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_flow_efficiency_jira.test.yaml b/src/ingestion/tests/e2e/metrics/tasks_flow_efficiency.test.yaml similarity index 86% rename from src/ingestion/tests/e2e/metrics/task_delivery_flow_efficiency_jira.test.yaml rename to src/ingestion/tests/e2e/metrics/tasks_flow_efficiency.test.yaml index 4161128cc..701d61c61 100644 --- a/src/ingestion/tests/e2e/metrics/task_delivery_flow_efficiency_jira.test.yaml +++ b/src/ingestion/tests/e2e/metrics/tasks_flow_efficiency.test.yaml @@ -65,39 +65,29 @@ bronze: - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: FEE1, changelog_id: "54009", unique_key: jira-test-FEE1-54009, created_at: "2026-03-25T00:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } cases: - - name: "flow_efficiency — custom scope (department of 5)" + - name: tasks.flow_efficiency request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-03-20' and metric_date le '2026-03-31'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-03-20", to: "2026-03-31" } + metrics: + - metric_key: tasks.flow_efficiency + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } expect: - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: flow_efficiency } - equal: { value: 100, median: 60, range_min: 20, range_max: 100, p25: 40, p75: 80 } - - - name: "flow_efficiency — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" + - metric: tasks.flow_efficiency + view: period + find: { entity_id: erin@example.com } + equal: { value: 100 } + - metric: tasks.flow_efficiency + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 100, p25: 40, median: 60, p75: 80, min: 20, max: 100, n: 5 } + - metric: tasks.flow_efficiency + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 100.0))" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_pickup_time_jira.test.yaml b/src/ingestion/tests/e2e/metrics/tasks_pickup_time.test.yaml similarity index 87% rename from src/ingestion/tests/e2e/metrics/task_delivery_pickup_time_jira.test.yaml rename to src/ingestion/tests/e2e/metrics/tasks_pickup_time.test.yaml index a20b7dc88..46c47d526 100644 --- a/src/ingestion/tests/e2e/metrics/task_delivery_pickup_time_jira.test.yaml +++ b/src/ingestion/tests/e2e/metrics/tasks_pickup_time.test.yaml @@ -64,39 +64,33 @@ bronze: - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: PUE1, changelog_id: "53010", unique_key: jira-test-PUE1-53010, created_at: "2026-03-27T00:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } cases: - - name: "pickup_time — custom scope (department of 5)" + - name: tasks.pickup_time request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-03-20' and metric_date le '2026-03-31'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-03-20", to: "2026-03-31" } + metrics: + - metric_key: tasks.pickup_time + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: histogram } expect: - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: pickup_time } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - - name: "pickup_time — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" + - metric: tasks.pickup_time + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: tasks.pickup_time + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: tasks.pickup_time + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 5.0))" + - metric: tasks.pickup_time + view: histogram + assert: "items.exists(h, h.entity_id == 'erin@example.com' && size(h.bins) > 0)" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_task_reopen_rate_jira.test.yaml b/src/ingestion/tests/e2e/metrics/tasks_reopen_rate.test.yaml similarity index 94% rename from src/ingestion/tests/e2e/metrics/task_delivery_task_reopen_rate_jira.test.yaml rename to src/ingestion/tests/e2e/metrics/tasks_reopen_rate.test.yaml index f89ff7b9a..2b69d127d 100644 --- a/src/ingestion/tests/e2e/metrics/task_delivery_task_reopen_rate_jira.test.yaml +++ b/src/ingestion/tests/e2e/metrics/tasks_reopen_rate.test.yaml @@ -114,39 +114,29 @@ bronze: - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: ROE1, changelog_id: "59035", unique_key: jira-test-ROE1-59035, created_at: "2026-03-29T09:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } cases: - - name: "task_reopen_rate — custom scope (department of 5)" + - name: tasks.reopen_rate request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-03-20' and metric_date le '2026-03-31'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-03-20", to: "2026-03-31" } + metrics: + - metric_key: tasks.reopen_rate + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } expect: - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: task_reopen_rate } - equal: { value: 80, median: 60, range_min: 20, range_max: 80, p25: 40, p75: 80 } - - - name: "task_reopen_rate — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" + - metric: tasks.reopen_rate + view: period + find: { entity_id: erin@example.com } + equal: { value: 100 } + - metric: tasks.reopen_rate + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 100, p25: 100, median: 100, p75: 100, min: 100, max: 100, n: 5 } + - metric: tasks.reopen_rate + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 100.0))" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_mean_time_to_resolution_jira.test.yaml b/src/ingestion/tests/e2e/metrics/tasks_resolution_time.test.yaml similarity index 84% rename from src/ingestion/tests/e2e/metrics/task_delivery_mean_time_to_resolution_jira.test.yaml rename to src/ingestion/tests/e2e/metrics/tasks_resolution_time.test.yaml index b4209e8de..7a726bd3e 100644 --- a/src/ingestion/tests/e2e/metrics/task_delivery_mean_time_to_resolution_jira.test.yaml +++ b/src/ingestion/tests/e2e/metrics/tasks_resolution_time.test.yaml @@ -58,39 +58,33 @@ bronze: - { $ref: templates/jira_task.yaml#/templates/jira_history, id_readable: MRE1, changelog_id: "52005", unique_key: jira-test-MRE1-52005, created_at: "2026-12-25T09:00:00.000+0000", author_account_id: erin-jira-acct, items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' } cases: - - name: "mean_time_to_resolution — custom scope (department of 5)" + - name: tasks.resolution_time request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-20' and metric_date le '2026-12-31'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-12-20", to: "2026-12-31" } + metrics: + - metric_key: tasks.resolution_time + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { view: histogram } expect: - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: mean_time_to_resolution } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - - name: "mean_time_to_resolution — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" + - metric: tasks.resolution_time + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: tasks.resolution_time + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: tasks.resolution_time + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 5.0))" + - metric: tasks.resolution_time + view: histogram + assert: "items.exists(h, h.entity_id == 'erin@example.com' && size(h.bins) > 0)" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_stale_in_progress_jira.test.yaml b/src/ingestion/tests/e2e/metrics/tasks_stale_in_progress.test.yaml similarity index 87% rename from src/ingestion/tests/e2e/metrics/task_delivery_stale_in_progress_jira.test.yaml rename to src/ingestion/tests/e2e/metrics/tasks_stale_in_progress.test.yaml index 39a017890..d55bae2db 100644 --- a/src/ingestion/tests/e2e/metrics/task_delivery_stale_in_progress_jira.test.yaml +++ b/src/ingestion/tests/e2e/metrics/tasks_stale_in_progress.test.yaml @@ -65,39 +65,29 @@ bronze: - { $ref: templates/jira_task.yaml#/templates/jira_issue, id: STE-5, jira_id: STE-5, unique_key: jira-test-STE-5, id_readable: STE5, created: "2026-01-01T09:00:00", custom_fields_json: '{"status":{"id":"3","name":"In Progress"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' } cases: - - name: "stale_in_progress — custom scope (department of 5, window includes today)" + - name: tasks.stale_in_progress request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2020-01-01' and metric_date le '2100-12-31'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-01-01", to: "2026-07-17" } + metrics: + - metric_key: tasks.stale_in_progress + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } expect: - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: stale_in_progress } - equal: { value: 5, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - - name: "stale_in_progress — window excluding today (no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-12-20' and metric_date le '2026-12-31'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" + - metric: tasks.stale_in_progress + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: tasks.stale_in_progress + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: tasks.stale_in_progress + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 5.0))" diff --git a/src/ingestion/tests/e2e/metrics/task_delivery_worklog_logging_accuracy_jira.test.yaml b/src/ingestion/tests/e2e/metrics/tasks_worklog_accuracy.test.yaml similarity index 87% rename from src/ingestion/tests/e2e/metrics/task_delivery_worklog_logging_accuracy_jira.test.yaml rename to src/ingestion/tests/e2e/metrics/tasks_worklog_accuracy.test.yaml index f506b89dc..2ce3a9cfe 100644 --- a/src/ingestion/tests/e2e/metrics/task_delivery_worklog_logging_accuracy_jira.test.yaml +++ b/src/ingestion/tests/e2e/metrics/tasks_worklog_accuracy.test.yaml @@ -69,39 +69,29 @@ bronze: - { $ref: templates/jira_task.yaml#/templates/jira_worklog, worklog_id: WL5-1, id_readable: WLE1, unique_key: jira-test-worklog-WLE-1, author_account_id: erin-jira-acct, started: "2026-03-25T10:00:00.000+0000", time_spent_seconds: 86400, _airbyte_raw_id: "00000000-0000-0000-0000-000000000005" } cases: - - name: "worklog_logging_accuracy — custom scope (department of 5)" + - name: tasks.worklog_accuracy request: - url: /v1/metrics/queries + url: /v1/metric-results method: POST body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-03-20' and metric_date le '2026-03-31'" - $orderby: metric_key + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-03-20", to: "2026-03-31" } + metrics: + - metric_key: tasks.worklog_accuracy + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } expect: - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: worklog_logging_accuracy } - equal: { value: 100, median: 60, range_min: 20, range_max: 100, p25: 40, p75: 80 } - - - name: "worklog_logging_accuracy — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000011 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" + - metric: tasks.worklog_accuracy + view: period + find: { entity_id: erin@example.com } + equal: { value: 100 } + - metric: tasks.worklog_accuracy + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 100, p25: 40, median: 60, p75: 80, min: 20, max: 100, n: 5 } + - metric: tasks.worklog_accuracy + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 100.0))" diff --git a/src/ingestion/tests/e2e/metrics/team_bullet_collab_emails_sent.test.yaml b/src/ingestion/tests/e2e/metrics/team_bullet_collab_emails_sent.test.yaml deleted file mode 100644 index f316a0b7e..000000000 --- a/src/ingestion/tests/e2e/metrics/team_bullet_collab_emails_sent.test.yaml +++ /dev/null @@ -1,63 +0,0 @@ -spec_version: 1 -description: > - Metric: m365_emails_sent — Team Bullet Collaboration (…0005), the TEAM variant - of the IC bullet (…0012); department-of-5 distribution + 6 stats in a single - custom window. - bronze -> silver -> gold: daily M365 email-activity per person; silver dedups to - one row per person/day; per person v_period = Σ emails sent over the window; the - TEAM bullet value = the department AVERAGE of those per-person sums (the IC bullet - …0012 returns the requested member's own sum). median/p25/p75/range = the - department (org_unit_id) distribution. - - Department: 5 Engineering members alice..erin, sendCount r*10 ∈ {10,20,30,40,50} - on 2026-12-25. Custom window 2026-12-20..12-31 -> per-person sums - {10,20,30,40,50} -> value = avg = 30 (== median). Empty window 2026-06 -> no items. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_m365.email_activity: - - { $ref: templates/m365_email.yaml#/templates/alice_email, reportRefreshDate: "2026-12-25", unique_key: team-sent-alice-20261225, sendCount: 10 } - - { $ref: templates/m365_email.yaml#/templates/bob_email, reportRefreshDate: "2026-12-25", unique_key: team-sent-bob-20261225, sendCount: 20 } - - { $ref: templates/m365_email.yaml#/templates/carol_email, reportRefreshDate: "2026-12-25", unique_key: team-sent-carol-20261225, sendCount: 30 } - - { $ref: templates/m365_email.yaml#/templates/dave_email, reportRefreshDate: "2026-12-25", unique_key: team-sent-dave-20261225, sendCount: 40 } - - { $ref: templates/m365_email.yaml#/templates/erin_email, reportRefreshDate: "2026-12-25", unique_key: team-sent-erin-20261225, sendCount: 50 } - -cases: - - name: "m365_emails_sent — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000005 - $filter: "org_unit_id eq 'Engineering' and metric_date ge '2026-12-20' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - find: { metric_key: m365_emails_sent } - equal: { value: 30, median: 30, range_min: 10, range_max: 50, p25: 20, p75: 40 } - - - name: "m365_emails_sent — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: collaboration - metric_id: 00000000-0000-0000-0001-000000000005 - $filter: "org_unit_id eq 'Engineering' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: collaboration - assert: "result.status == 'ok'" - - in: collaboration - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/team_bullet_task_delivery_tasks_completed.test.yaml b/src/ingestion/tests/e2e/metrics/team_bullet_task_delivery_tasks_completed.test.yaml deleted file mode 100644 index 94657c249..000000000 --- a/src/ingestion/tests/e2e/metrics/team_bullet_task_delivery_tasks_completed.test.yaml +++ /dev/null @@ -1,300 +0,0 @@ -spec_version: 1 -description: > - Metric: tasks_completed - Team Bullet Task Delivery (...0003), the TEAM variant - of the IC bullet (...0011); department-of-5 distribution + 6 stats in a single - custom window. - - bronze -> silver -> gold: jira_issue (Closed) + jira_issue_history - (Status->Closed event = metric_date) + jira_user. Per person, v_period = COUNT - of tasks closed in the window; the TEAM bullet value = the department AVERAGE of - those per-person counts (the IC bullet ...0011 returns the requested member's own - count instead). median/p25/p75/range = the department (org_unit_id) distribution. - - Department: 5 Engineering members alice..erin of rank r in 1..5, each closing r - Tasks on 2026-12-25. Custom window 2026-12-20..12-31 -> per-person counts - {1,2,3,4,5} -> value = avg = 3 (== median; symmetric AP dept). Filtered by the - department (org_unit_id) string. Empty window 2026-06 -> no items. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - - bronze_jira.jira_user: - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-alice-jira-acct, account_id: alice-jira-acct, email: alice@example.com, display_name: Alice Alpha } - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-bob-jira-acct, account_id: bob-jira-acct, email: bob@example.com, display_name: Bob Beta } - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-carol-jira-acct, account_id: carol-jira-acct, email: carol@example.com, display_name: Carol Gamma } - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-dave-jira-acct, account_id: dave-jira-acct, email: dave@example.com, display_name: Dave Delta } - - { $ref: templates/jira_task.yaml#/templates/jira_user, unique_key: jira-test-erin-jira-acct, account_id: erin-jira-acct, email: erin@example.com, display_name: Erin Epsilon } - - bronze_jira.jira_statuses: - - $ref: templates/jira_task.yaml#/templates/status_open - - $ref: templates/jira_task.yaml#/templates/status_in_progress - - $ref: templates/jira_task.yaml#/templates/status_closed - - bronze_jira.jira_fields: - - $ref: templates/jira_task.yaml#/templates/field_status - - $ref: templates/jira_task.yaml#/templates/field_assignee - - $ref: templates/jira_task.yaml#/templates/field_issuetype - - $ref: templates/jira_task.yaml#/templates/field_duedate - - bronze_jira.jira_issue: - # ── Alice Alpha (rank 1) ── - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TAW-1 - jira_id: TAW-1 - unique_key: jira-test-TAW-1 - id_readable: TAW1 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"alice-jira-acct","displayName":"Alice Alpha"}}' - # ── Bob Beta (rank 2) ── - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TBW-1 - jira_id: TBW-1 - unique_key: jira-test-TBW-1 - id_readable: TBW1 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"bob-jira-acct","displayName":"Bob Beta"}}' - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TBW-2 - jira_id: TBW-2 - unique_key: jira-test-TBW-2 - id_readable: TBW2 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"bob-jira-acct","displayName":"Bob Beta"}}' - # ── Carol Gamma (rank 3) ── - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TCW-1 - jira_id: TCW-1 - unique_key: jira-test-TCW-1 - id_readable: TCW1 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TCW-2 - jira_id: TCW-2 - unique_key: jira-test-TCW-2 - id_readable: TCW2 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TCW-3 - jira_id: TCW-3 - unique_key: jira-test-TCW-3 - id_readable: TCW3 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"carol-jira-acct","displayName":"Carol Gamma"}}' - # ── Dave Delta (rank 4) ── - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TDW-1 - jira_id: TDW-1 - unique_key: jira-test-TDW-1 - id_readable: TDW1 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TDW-2 - jira_id: TDW-2 - unique_key: jira-test-TDW-2 - id_readable: TDW2 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TDW-3 - jira_id: TDW-3 - unique_key: jira-test-TDW-3 - id_readable: TDW3 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TDW-4 - jira_id: TDW-4 - unique_key: jira-test-TDW-4 - id_readable: TDW4 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"dave-jira-acct","displayName":"Dave Delta"}}' - # ── Erin Epsilon (rank 5) ── - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TEW-1 - jira_id: TEW-1 - unique_key: jira-test-TEW-1 - id_readable: TEW1 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TEW-2 - jira_id: TEW-2 - unique_key: jira-test-TEW-2 - id_readable: TEW2 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TEW-3 - jira_id: TEW-3 - unique_key: jira-test-TEW-3 - id_readable: TEW3 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TEW-4 - jira_id: TEW-4 - unique_key: jira-test-TEW-4 - id_readable: TEW4 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' - - $ref: templates/jira_task.yaml#/templates/jira_issue - id: TEW-5 - jira_id: TEW-5 - unique_key: jira-test-TEW-5 - id_readable: TEW5 - created: "2026-01-05T09:00:00" - custom_fields_json: '{"status":{"id":"6","name":"Closed"},"issuetype":{"id":"10001","name":"Task"},"assignee":{"accountId":"erin-jira-acct","displayName":"Erin Epsilon"}}' - - bronze_jira.jira_issue_history: - # ── Alice Alpha (rank 1) ── - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TAW1 - changelog_id: "50000" - unique_key: jira-test-TAW1-50000 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: alice-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - # ── Bob Beta (rank 2) ── - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TBW1 - changelog_id: "50001" - unique_key: jira-test-TBW1-50001 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: bob-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TBW2 - changelog_id: "50002" - unique_key: jira-test-TBW2-50002 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: bob-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - # ── Carol Gamma (rank 3) ── - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TCW1 - changelog_id: "50003" - unique_key: jira-test-TCW1-50003 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: carol-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TCW2 - changelog_id: "50004" - unique_key: jira-test-TCW2-50004 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: carol-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TCW3 - changelog_id: "50005" - unique_key: jira-test-TCW3-50005 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: carol-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - # ── Dave Delta (rank 4) ── - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TDW1 - changelog_id: "50006" - unique_key: jira-test-TDW1-50006 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: dave-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TDW2 - changelog_id: "50007" - unique_key: jira-test-TDW2-50007 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: dave-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TDW3 - changelog_id: "50008" - unique_key: jira-test-TDW3-50008 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: dave-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TDW4 - changelog_id: "50009" - unique_key: jira-test-TDW4-50009 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: dave-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - # ── Erin Epsilon (rank 5) ── - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TEW1 - changelog_id: "50010" - unique_key: jira-test-TEW1-50010 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: erin-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TEW2 - changelog_id: "50011" - unique_key: jira-test-TEW2-50011 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: erin-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TEW3 - changelog_id: "50012" - unique_key: jira-test-TEW3-50012 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: erin-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TEW4 - changelog_id: "50013" - unique_key: jira-test-TEW4-50013 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: erin-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - - $ref: templates/jira_task.yaml#/templates/jira_history - id_readable: TEW5 - changelog_id: "50014" - unique_key: jira-test-TEW5-50014 - created_at: "2026-12-25T14:00:00.000+0000" - author_account_id: erin-jira-acct - items: '[{"field":"Status","fieldId":"status","fromString":"In Progress","from":"3","toString":"Closed","to":"6"}]' - -cases: - - name: "tasks_completed — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000003 - $filter: "org_unit_id eq 'Engineering' and metric_date ge '2026-12-20' and metric_date le '2026-12-31'" - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - find: { metric_key: tasks_completed } - equal: { value: 3, median: 3, range_min: 1, range_max: 5, p25: 2, p75: 4 } - - - name: "tasks_completed — empty window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: task_delivery - metric_id: 00000000-0000-0000-0001-000000000003 - $filter: "org_unit_id eq 'Engineering' and metric_date ge '2026-06-01' and metric_date le '2026-06-30'" - expect: - - assert: "status == 200" - - in: task_delivery - assert: "result.status == 'ok'" - - in: task_delivery - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/templates/claude_team_overage.yaml b/src/ingestion/tests/e2e/metrics/templates/claude_team_overage.yaml deleted file mode 100644 index b3b70f5da..000000000 --- a/src/ingestion/tests/e2e/metrics/templates/claude_team_overage.yaml +++ /dev/null @@ -1,45 +0,0 @@ -# Reusable Claude Team overage-spend records (bronze_claude_team.claude_team_overage_spend). -# Base carries every schema column (unused = null) incl. the 4 _airbyte_* CDK -# columns. The metric date is the snapshot's collection day, derived from -# _airbyte_extracted_at (set in base to 2026-01-05) — there is no bronze date -# field. overage_cents = greatest(0, used_credits − monthly_credit_limit), both -# already in cents. Variants override identity (account_email, account_uuid); -# the fields under test (unique_key, monthly_credit_limit, used_credits) are set -# in each test's `bronze`. -templates: - overage_seat: - _airbyte_raw_id: "00000000-0000-0000-0000-000000000000" - _airbyte_extracted_at: "2026-01-05T00:00:00Z" - _airbyte_meta: "{}" - _airbyte_generation_id: 0 - tenant_id: "00000000-0000-0000-0000-000000000000" - source_id: "claude-team-test" - unique_key: null - collected_at: "2026-01-05T00:00:00Z" - data_source: "insight_claude_team" - account_uuid: null - account_email: null - account_name: "Test Seat" - seat_tier: null - is_enabled: true - monthly_credit_limit: null - used_credits: null - currency: "USD" - out_of_credits: null - used_credits_basis: null - limit_type: null - - alice: - $ref: "#/templates/overage_seat" - account_email: alice@example.com - account_uuid: seat-alice - - bob: - $ref: "#/templates/overage_seat" - account_email: bob@example.com - account_uuid: seat-bob - - carol: - $ref: "#/templates/overage_seat" - account_email: carol@example.com - account_uuid: seat-carol diff --git a/src/ingestion/tests/e2e/metrics/templates/confluence_wiki_footer_comments.yaml b/src/ingestion/tests/e2e/metrics/templates/confluence_wiki_footer_comments.yaml deleted file mode 100644 index b83e4a02b..000000000 --- a/src/ingestion/tests/e2e/metrics/templates/confluence_wiki_footer_comments.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Reusable records for confluence_wiki_footer_comments.yaml (base carries every schema column; unused = null). -templates: - confluence_comment: - _airbyte_raw_id: "00000000-0000-0000-0000-000000000000" - _airbyte_extracted_at: "2026-01-05T00:00:00Z" - _airbyte_meta: "{}" - _airbyte_generation_id: 0 - unique_key: null - tenant_id: "00000000-0000-0000-0000-000000000000" - source_id: "wiki-test" - page_id: null - comment_id: null - parent_comment_id: null - author_id: null - created_at: null - resolution_status: null - - c: - $ref: "#/templates/confluence_comment" - author_id: conf-commenter diff --git a/src/ingestion/tests/e2e/metrics/templates/confluence_wiki_pages.yaml b/src/ingestion/tests/e2e/metrics/templates/confluence_wiki_pages.yaml deleted file mode 100644 index bf4b4fd76..000000000 --- a/src/ingestion/tests/e2e/metrics/templates/confluence_wiki_pages.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# Reusable records for confluence_wiki_pages.yaml (base carries every schema column; unused = null). -templates: - confluence_page: - _airbyte_raw_id: "00000000-0000-0000-0000-000000000000" - _airbyte_extracted_at: "2026-01-05T00:00:00Z" - _airbyte_meta: "{}" - _airbyte_generation_id: 0 - unique_key: null - tenant_id: "00000000-0000-0000-0000-000000000000" - source_id: "wiki-test" - page_id: null - space_id: null - title: null - status: null - author_id: null - last_editor_id: null - parent_page_id: null - version_number: null - created_at: null - updated_at: null - collected_at: "2026-01-05T00:00:00Z" - - alice: - $ref: "#/templates/confluence_page" - author_id: alice@example.com - - bob: - $ref: "#/templates/confluence_page" - author_id: bob@example.com - - carol: - $ref: "#/templates/confluence_page" - author_id: carol@example.com - - dave: - $ref: "#/templates/confluence_page" - author_id: dave@example.com - - erin: - $ref: "#/templates/confluence_page" - author_id: erin@example.com diff --git a/src/ingestion/tests/e2e/metrics/templates/git_activity.yaml b/src/ingestion/tests/e2e/metrics/templates/git_activity.yaml new file mode 100644 index 000000000..7ccda758b --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/templates/git_activity.yaml @@ -0,0 +1,55 @@ +templates: + base: + _airbyte_raw_id: "00000000-0000-0000-0000-000000000000" + _airbyte_extracted_at: "2026-10-02T00:00:00Z" + _airbyte_meta: "{}" + _airbyte_generation_id: 0 + tenant_id: "00000000-0000-0000-0000-000000000000" + source_id: git-test + unique_key: null + commit: + $ref: "#/templates/base" + repo_owner: constructor + repo_name: insight + oid: null + branch_name: main + author_name: null + author_email: null + committer_name: null + committer_email: null + message: change + committed_date: "2026-10-01T09:00:00Z" + changed_files: 1 + additions: 0 + deletions: 0 + parent_hashes: [parent] + file_change: + $ref: "#/templates/base" + repo_owner: constructor + repo_name: insight + commit_hash: null + filename: src/main.rs + status: modified + additions: 0 + deletions: 0 + source_type: direct_push + pull_request: + $ref: "#/templates/base" + repo_owner: constructor + repo_name: insight + database_id: 0 + number: 0 + title: change + body: "" + state: MERGED + author_login: null + author_email: null + head_ref: feature + base_ref: main + created_at: "2026-10-01T08:00:00Z" + updated_at: "2026-10-01T13:00:00Z" + closed_at: null + merge_commit_sha: null + changed_files: 1 + additions: 0 + deletions: 0 diff --git a/src/ingestion/tests/e2e/metrics/templates/jira_task.yaml b/src/ingestion/tests/e2e/metrics/templates/jira_task.yaml index 2839bbc89..66275bde4 100644 --- a/src/ingestion/tests/e2e/metrics/templates/jira_task.yaml +++ b/src/ingestion/tests/e2e/metrics/templates/jira_task.yaml @@ -35,6 +35,7 @@ templates: jira_user: unique_key: null source_id: jira-test + tenant_id: "00000000-0000-0000-0000-000000000000" account_id: null email: null display_name: null diff --git a/src/ingestion/tests/e2e/metrics/templates/m365_sharepoint.yaml b/src/ingestion/tests/e2e/metrics/templates/m365_sharepoint.yaml deleted file mode 100644 index 54b4f6b03..000000000 --- a/src/ingestion/tests/e2e/metrics/templates/m365_sharepoint.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# Reusable SharePoint-activity records (bronze_m365.sharepoint_activity). -# Base lists every schema field (counts null) incl. visitedPageCount; per-person -# records override only userPrincipalName. Feeds class_collab_document_activity -# (product = sharepoint). -templates: - m365_sharepoint: - _airbyte_raw_id: "00000000-0000-0000-0000-000000000000" - _airbyte_extracted_at: "2026-01-05T00:00:00" - _airbyte_meta: "{}" - _airbyte_generation_id: 0 - tenant_id: "00000000-0000-0000-0000-000000000000" - source_id: "m365-test" - unique_key: null - userPrincipalName: null - reportPeriod: "D1" - reportRefreshDate: null - lastActivityDate: null - viewedOrEditedFileCount: null - syncedFileCount: null - sharedInternallyFileCount: null - sharedExternallyFileCount: null - visitedPageCount: null - - alice_sharepoint: - $ref: "#/templates/m365_sharepoint" - userPrincipalName: alice@example.com - - bob_sharepoint: - $ref: "#/templates/m365_sharepoint" - userPrincipalName: bob@example.com - - carol_sharepoint: - $ref: "#/templates/m365_sharepoint" - userPrincipalName: carol@example.com diff --git a/src/ingestion/tests/e2e/metrics/templates/people.yaml b/src/ingestion/tests/e2e/metrics/templates/people.yaml index 58093f5c1..fac18c4fc 100644 --- a/src/ingestion/tests/e2e/metrics/templates/people.yaml +++ b/src/ingestion/tests/e2e/metrics/templates/people.yaml @@ -36,7 +36,7 @@ templates: hireDate: null originalHireDate: null terminationDate: null - lastChanged: null + lastChanged: "2026-01-01T00:00:00Z" employmentHistoryStatus: null raw_data: null diff --git a/src/ingestion/tests/e2e/metrics/templates/wiki_page_versions.yaml b/src/ingestion/tests/e2e/metrics/templates/wiki_page_versions.yaml new file mode 100644 index 000000000..7ff86a75a --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/templates/wiki_page_versions.yaml @@ -0,0 +1,16 @@ +templates: + revision: + _airbyte_raw_id: "00000000-0000-0000-0000-000000000000" + _airbyte_extracted_at: "2026-05-07T00:00:00Z" + _airbyte_meta: "{}" + _airbyte_generation_id: 0 + unique_key: null + tenant_id: "00000000-0000-0000-0000-000000000000" + source_id: wiki-test + page_id: null + revision_id: null + author_id: null + author_email: null + created_at: null + message: null + collected_at: "2026-05-07T00:00:00Z" diff --git a/src/ingestion/tests/e2e/metrics/templates/zoom.yaml b/src/ingestion/tests/e2e/metrics/templates/zoom.yaml deleted file mode 100644 index 1c13af459..000000000 --- a/src/ingestion/tests/e2e/metrics/templates/zoom.yaml +++ /dev/null @@ -1,72 +0,0 @@ -# Reusable Zoom records (bronze_zoom.participants + bronze_zoom.meetings). -# The base carries EVERY schema column (so a merged row is complete); the fields -# under test (join_time / leave_time / meeting_uuid / participant_uuid / -# unique_key) are set in each test's `bronze`. Meeting duration = -# dateDiff(join_time, leave_time); video is gated by `camera` (NULL/'' => 0) and -# screen-share by the share_* flags, so a participant with audio only contributes -# audio seconds as the longest modality. -# -# `unique_key` is the bronze row key (the table is RMT ORDER BY unique_key): give -# every DISTINCT seeded row a distinct unique_key; an Airbyte re-sync duplicate -# repeats the row verbatim (same unique_key). -# -# zoom_meeting rows feed session stitching (zoom__meeting_sessions): consecutive -# sessions (`uuid`) of the same Zoom Meeting ID (`id`) with an end→start gap -# ≤ 5 min collapse into one logical meeting. NOTE: the sessions model only reads -# bronze meetings whose `_airbyte_extracted_at` is within 90 days of REAL now() -# — override it per test to a date near the seeded meeting dates. -templates: - zoom_participant: - _airbyte_raw_id: "00000000-0000-0000-0000-000000000000" - _airbyte_extracted_at: "2026-01-05T00:00:00" - _airbyte_meta: "{}" - _airbyte_generation_id: 0 - tenant_id: "00000000-0000-0000-0000-000000000000" - source_id: "zoom-test" - unique_key: null - email: null - user_name: null - meeting_uuid: null - participant_uuid: null - join_time: null - leave_time: null - camera: null - share_desktop: null - share_application: null - share_whiteboard: null - video_connection_type: null - - zoom_meeting: - _airbyte_raw_id: "00000000-0000-0000-0000-000000000000" - _airbyte_extracted_at: "2026-01-05T00:00:00" - _airbyte_meta: "{}" - _airbyte_generation_id: 0 - tenant_id: "00000000-0000-0000-0000-000000000000" - source_id: "zoom-test" - unique_key: null - id: null - uuid: null - start_time: null - end_time: null - has_video: false - has_screen_share: false - - alice_zoom: - $ref: "#/templates/zoom_participant" - email: alice@example.com - - bob_zoom: - $ref: "#/templates/zoom_participant" - email: bob@example.com - - carol_zoom: - $ref: "#/templates/zoom_participant" - email: carol@example.com - - dave_zoom: - $ref: "#/templates/zoom_participant" - email: dave@example.com - - erin_zoom: - $ref: "#/templates/zoom_participant" - email: erin@example.com diff --git a/src/ingestion/tests/e2e/metrics/templates/zulip.yaml b/src/ingestion/tests/e2e/metrics/templates/zulip.yaml deleted file mode 100644 index 1f68872af..000000000 --- a/src/ingestion/tests/e2e/metrics/templates/zulip.yaml +++ /dev/null @@ -1,78 +0,0 @@ -# Reusable Zulip records (bronze_zulip_proxy.users + .messages). -# A record is a plain field map; an optional `$ref` inherits from another record -# and sibling keys override it (closest wins). Nested `$ref: "#/..."` stays local -# to this file. -# -# Each base record lists EVERY schema column so a merged bronze row is complete; -# unused fields default to null. The 4 `_airbyte_*` CDK columns are included -# (the chat model dedups by `uniq` ORDER BY `_airbyte_extracted_at`). -# -# Identity: messages join users on `sender_id = id` (+ tenant_id/source_id), so -# the user emails here must match the bamboohr people emails (templates/people.yaml) -# for the collab bullet's `lower(email) = insight.people.person_id` join. -templates: - # ── users ──────────────────────────────────────────────────────────── - zulip_user: - _airbyte_raw_id: "00000000-0000-0000-0000-000000000000" - _airbyte_extracted_at: "2026-01-05T00:00:00" - _airbyte_meta: "{}" - _airbyte_generation_id: 0 - tenant_id: "00000000-0000-0000-0000-000000000000" - source_id: "zulip-proxy-test" - id: null - uuid: null - email: null - full_name: null - role: 400 - is_active: true - recipient_id: null - unique_key: null - - alice_user: - $ref: "#/templates/zulip_user" - id: 1 - email: alice@example.com - full_name: Alice Alpha - unique_key: zulip-user-1 - - bob_user: - $ref: "#/templates/zulip_user" - id: 2 - email: bob@example.com - full_name: Bob Beta - unique_key: zulip-user-2 - - carol_user: - $ref: "#/templates/zulip_user" - id: 3 - email: carol@example.com - full_name: Carol Gamma - unique_key: zulip-user-3 - - # ── messages ───────────────────────────────────────────────────────── - # The proxy ships one row per (sender, bucket) carrying a `count`. The chat - # model dedups by `uniq` then sums `count` per (sender email, date). - zulip_message: - _airbyte_raw_id: "00000000-0000-0000-0000-000000000000" - _airbyte_extracted_at: "2026-01-05T00:00:00" - _airbyte_meta: "{}" - _airbyte_generation_id: 0 - tenant_id: "00000000-0000-0000-0000-000000000000" - source_id: "zulip-proxy-test" - uniq: null - sender_id: null - count: null - created_at: "2026-01-05T10:00:00" - unique_key: null - - alice_message: - $ref: "#/templates/zulip_message" - sender_id: 1 - - bob_message: - $ref: "#/templates/zulip_message" - sender_id: 2 - - carol_message: - $ref: "#/templates/zulip_message" - sender_id: 3 diff --git a/src/ingestion/tests/e2e/metrics/test_fixtures.py b/src/ingestion/tests/e2e/metrics/test_fixtures.py index e9317a987..45e84f628 100644 --- a/src/ingestion/tests/e2e/metrics/test_fixtures.py +++ b/src/ingestion/tests/e2e/metrics/test_fixtures.py @@ -1,19 +1,3 @@ -"""Parametrized runner for `*.test.yaml` fixtures. - -One pytest invocation per discovered `.test.yaml`. Implements -`cpt-bronze-to-api-e2e-algo-yaml-execute-test`: - - truncate prior test's tables → - seed resolved bronze records → - two-pass dbt build (staging, then silver) → - recreate gold views (reapply migrations) → - refresh intermediates → - POST /v1/metrics/queries per case → - evaluate expect rules - -Discovery + per-test fixtures live in `../conftest.py`. -""" - from __future__ import annotations import logging @@ -26,7 +10,6 @@ from lib.enrich import EnrichRunner from lib.expect_engine import evaluate_case from lib.fixture_loader import TestYaml -from lib.migration_applier import refresh_intermediates, reapply_migrations from lib.worker import WorkerContext pytestmark = pytest.mark.fixture @@ -41,11 +24,10 @@ def test_metric_smoke( analytics: AnalyticsProcess, worker_ctx: WorkerContext, ) -> None: - # 1. Clear what the prior test wrote (no-op on the first test). ch_seeder.truncate_touched() # 2. Seed this test's resolved bronze records. - ch_seeder.seed_bronze(test_yaml.bronze) + ch_seeder.seed_bronze(test_yaml.bronze, test_yaml.schemas) # 3. Build the dbt models the seeded tables feed: staging first (the `+` # pulls __bronze_promoted), then the silver class models. @@ -94,21 +76,28 @@ def test_metric_smoke( # ACTUALLY ran (had a source_id) contribute their ephemeral targets — otherwise # we'd build silver that depends on enrich output that was never produced. silver_set = set(silver) + silver_set.discard("identity_inputs") for step in ran_enrich_steps: silver_set.update(dbt_runner.ephemeral_silver_targets(step.name)) - if silver_set: + run_only_silver = silver_set & {"class_hr_working_hours"} + tested_silver = silver_set - run_only_silver + if tested_silver: # Record before building (same rationale as staging above): a build that # raises partway still leaves the targets in the truncate ledger for the # next test to clean. - for cls in silver_set: + for cls in tested_silver: + ch_seeder.ledger.record("silver", cls) + dbt_runner.build(" ".join(sorted(tested_silver)), worker_ctx=worker_ctx) + if run_only_silver: + for cls in run_only_silver: ch_seeder.ledger.record("silver", cls) - dbt_runner.build(" ".join(sorted(silver_set)), worker_ctx=worker_ctx) + dbt_runner.run(" ".join(sorted(run_only_silver)), worker_ctx=worker_ctx) + if "class_collab_meeting_activity" in silver_set: + ch_seeder.ledger.record("silver", "class_focus_metrics") + dbt_runner.run("class_focus_metrics", worker_ctx=worker_ctx, full_refresh=True) - # 4. Recreate gold views against the now-real silver schema (fixes the rig-only - # Code 80 nullability mismatch on date-filtered reads), then refresh MVs. if staging or silver_set or ran_enrich_steps: - reapply_migrations(ch_seeder.cfg) - refresh_intermediates(ch_seeder.cfg) + dbt_runner.run("tag:gold", worker_ctx=worker_ctx) # 5. Run each case's batch request and evaluate its expect rules. for case in test_yaml.cases: diff --git a/src/ingestion/tests/e2e/metrics/wiki_comments.test.yaml b/src/ingestion/tests/e2e/metrics/wiki_comments.test.yaml new file mode 100644 index 000000000..67dd8cbc8 --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/wiki_comments.test.yaml @@ -0,0 +1,72 @@ +spec_version: 1 +description: > + Metric: wiki_comments (Confluence) — IC Bullet Wiki (…0041), #1440. SINGLE-WINDOW fixture. + Department of 5 Engineering members (alice…erin); requested IC member = erin. + One narrow custom window (2026-08-02 … 2026-08-08) holds ALL of this + fixture's rows (dated 2026-08-05); a second case probes an empty window. + + ISOLATION: silver.class_wiki_pages / class_wiki_engagement are incremental + delete+insert tables the e2e rig resets only at SESSION START — the per-test + ledger truncates only the bronze a fixture seeds, so a sibling fixture's silver + rows persist across tests. Every wiki fixture is therefore pinned to its OWN + disjoint date + window so a leaked foreign row falls OUTSIDE this window and + cannot perturb the stats. Anchor pages are dated 2025-06-15 (outside ALL windows) for the engagement INNER JOIN; per-person value = Σ comments received in the window = m → dept {1,2,3,4,5}. + +bronze: + bronze_bamboohr.employees: + - $ref: templates/people.yaml#/templates/alice + - $ref: templates/people.yaml#/templates/bob + - $ref: templates/people.yaml#/templates/carol + - $ref: templates/people.yaml#/templates/dave + - $ref: templates/people.yaml#/templates/erin + bronze_outline.wiki_pages: + - { $ref: "templates/outline_wiki_pages.yaml#/templates/alice", page_id: cp-alice-1, unique_key: cf-alice-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) + - { $ref: "templates/outline_wiki_pages.yaml#/templates/bob", page_id: cp-bob-1, unique_key: cf-bob-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) + - { $ref: "templates/outline_wiki_pages.yaml#/templates/carol", page_id: cp-carol-1, unique_key: cf-carol-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) + - { $ref: "templates/outline_wiki_pages.yaml#/templates/dave", page_id: cp-dave-1, unique_key: cf-dave-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) + - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: cp-erin-1, unique_key: cf-erin-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) + bronze_outline.wiki_comments: + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-alice-1, comment_id: falice-1, unique_key: fc-alice-1, created_at: "2026-08-05T01:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-bob-1, comment_id: fbob-1, unique_key: fc-bob-1, created_at: "2026-08-05T01:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-bob-1, comment_id: fbob-2, unique_key: fc-bob-2, created_at: "2026-08-05T02:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-carol-1, comment_id: fcarol-1, unique_key: fc-carol-1, created_at: "2026-08-05T01:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-carol-1, comment_id: fcarol-2, unique_key: fc-carol-2, created_at: "2026-08-05T02:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-carol-1, comment_id: fcarol-3, unique_key: fc-carol-3, created_at: "2026-08-05T03:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-dave-1, comment_id: fdave-1, unique_key: fc-dave-1, created_at: "2026-08-05T01:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-dave-1, comment_id: fdave-2, unique_key: fc-dave-2, created_at: "2026-08-05T02:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-dave-1, comment_id: fdave-3, unique_key: fc-dave-3, created_at: "2026-08-05T03:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-dave-1, comment_id: fdave-4, unique_key: fc-dave-4, created_at: "2026-08-05T04:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-erin-1, comment_id: ferin-1, unique_key: fc-erin-1, created_at: "2026-08-05T01:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-erin-1, comment_id: ferin-2, unique_key: fc-erin-2, created_at: "2026-08-05T02:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-erin-1, comment_id: ferin-3, unique_key: fc-erin-3, created_at: "2026-08-05T03:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-erin-1, comment_id: ferin-4, unique_key: fc-erin-4, created_at: "2026-08-05T04:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-erin-1, comment_id: ferin-5, unique_key: fc-erin-5, created_at: "2026-08-05T05:00:00Z" } + - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: cp-erin-1, comment_id: ferin-1, unique_key: fc-erin-1, created_at: "2026-08-05T01:00:00Z" } # re-sync dup → not doubled + +cases: + - name: wiki.comments + request: + url: /v1/metric-results + method: POST + body: + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-08-02", to: "2026-08-08" } + metrics: + - metric_key: wiki.comments + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + expect: + - assert: "status == 200" + - metric: wiki.comments + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: wiki.comments + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: wiki.comments + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 5.0))" diff --git a/src/ingestion/tests/e2e/metrics/wiki_confluence_active_authors.test.yaml b/src/ingestion/tests/e2e/metrics/wiki_confluence_active_authors.test.yaml deleted file mode 100644 index f37e795cd..000000000 --- a/src/ingestion/tests/e2e/metrics/wiki_confluence_active_authors.test.yaml +++ /dev/null @@ -1,66 +0,0 @@ -spec_version: 1 -description: > - Metric: wiki_active_authors (Confluence) — IC Bullet Wiki (…0041), #1440. SINGLE-WINDOW fixture. - Department of 5 Engineering members (alice…erin); requested IC member = erin. - One narrow custom window (2026-10-04 … 2026-10-10) holds ALL of this - fixture's rows (dated 2026-10-07); a second case probes an empty window. - - ISOLATION: silver.class_wiki_pages / class_wiki_engagement are incremental - delete+insert tables the e2e rig resets only at SESSION START — the per-test - ledger truncates only the bronze a fixture seeds, so a sibling fixture's silver - rows persist across tests. Every wiki fixture is therefore pinned to its OWN - disjoint date + window so a leaked foreign row falls OUTSIDE this window and - cannot perturb the stats. All 5 members author a page in the window; value=1 (erin active), median=0 (rule), range_max = count of active authors = 5. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - bronze_confluence.wiki_pages: - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/alice", page_id: cp-alice-1, unique_key: cf-alice-1, version_number: 1, created_at: "2026-10-07T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/bob", page_id: cp-bob-1, unique_key: cf-bob-1, version_number: 1, created_at: "2026-10-07T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/carol", page_id: cp-carol-1, unique_key: cf-carol-1, version_number: 1, created_at: "2026-10-07T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/dave", page_id: cp-dave-1, unique_key: cf-dave-1, version_number: 1, created_at: "2026-10-07T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/erin", page_id: cp-erin-1, unique_key: cf-erin-1, version_number: 1, created_at: "2026-10-07T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/erin", page_id: cp-erin-1, unique_key: cf-erin-1, version_number: 1, created_at: "2026-10-07T00:00:00Z" } # re-sync dup → active stays 1 - -cases: - - name: "wiki_active_authors (Confluence) — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-10-04' and metric_date le '2026-10-10'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - find: { metric_key: wiki_active_authors } - equal: { value: 1, median: 0, range_min: 0, range_max: 5 } - - - name: "wiki_active_authors (Confluence) — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-07-01' and metric_date le '2026-07-07'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/wiki_confluence_comments.test.yaml b/src/ingestion/tests/e2e/metrics/wiki_confluence_comments.test.yaml deleted file mode 100644 index 8a793bed7..000000000 --- a/src/ingestion/tests/e2e/metrics/wiki_confluence_comments.test.yaml +++ /dev/null @@ -1,82 +0,0 @@ -spec_version: 1 -description: > - Metric: wiki_comments (Confluence) — IC Bullet Wiki (…0041), #1440. SINGLE-WINDOW fixture. - Department of 5 Engineering members (alice…erin); requested IC member = erin. - One narrow custom window (2026-08-02 … 2026-08-08) holds ALL of this - fixture's rows (dated 2026-08-05); a second case probes an empty window. - - ISOLATION: silver.class_wiki_pages / class_wiki_engagement are incremental - delete+insert tables the e2e rig resets only at SESSION START — the per-test - ledger truncates only the bronze a fixture seeds, so a sibling fixture's silver - rows persist across tests. Every wiki fixture is therefore pinned to its OWN - disjoint date + window so a leaked foreign row falls OUTSIDE this window and - cannot perturb the stats. Anchor pages are dated 2025-06-15 (outside ALL windows) for the engagement INNER JOIN; per-person value = Σ comments received in the window = m → dept {1,2,3,4,5}. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - bronze_confluence.wiki_pages: - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/alice", page_id: cp-alice-1, unique_key: cf-alice-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/bob", page_id: cp-bob-1, unique_key: cf-bob-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/carol", page_id: cp-carol-1, unique_key: cf-carol-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/dave", page_id: cp-dave-1, unique_key: cf-dave-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/erin", page_id: cp-erin-1, unique_key: cf-erin-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) - bronze_confluence.wiki_footer_comments: - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-alice-1, comment_id: falice-1, unique_key: fc-alice-1, created_at: "2026-08-05T01:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-bob-1, comment_id: fbob-1, unique_key: fc-bob-1, created_at: "2026-08-05T01:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-bob-1, comment_id: fbob-2, unique_key: fc-bob-2, created_at: "2026-08-05T02:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-carol-1, comment_id: fcarol-1, unique_key: fc-carol-1, created_at: "2026-08-05T01:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-carol-1, comment_id: fcarol-2, unique_key: fc-carol-2, created_at: "2026-08-05T02:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-carol-1, comment_id: fcarol-3, unique_key: fc-carol-3, created_at: "2026-08-05T03:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-dave-1, comment_id: fdave-1, unique_key: fc-dave-1, created_at: "2026-08-05T01:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-dave-1, comment_id: fdave-2, unique_key: fc-dave-2, created_at: "2026-08-05T02:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-dave-1, comment_id: fdave-3, unique_key: fc-dave-3, created_at: "2026-08-05T03:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-dave-1, comment_id: fdave-4, unique_key: fc-dave-4, created_at: "2026-08-05T04:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-erin-1, comment_id: ferin-1, unique_key: fc-erin-1, created_at: "2026-08-05T01:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-erin-1, comment_id: ferin-2, unique_key: fc-erin-2, created_at: "2026-08-05T02:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-erin-1, comment_id: ferin-3, unique_key: fc-erin-3, created_at: "2026-08-05T03:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-erin-1, comment_id: ferin-4, unique_key: fc-erin-4, created_at: "2026-08-05T04:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-erin-1, comment_id: ferin-5, unique_key: fc-erin-5, created_at: "2026-08-05T05:00:00Z" } - - { $ref: "templates/confluence_wiki_footer_comments.yaml#/templates/c", page_id: cp-erin-1, comment_id: ferin-1, unique_key: fc-erin-1, created_at: "2026-08-05T01:00:00Z" } # re-sync dup → not doubled - -cases: - - name: "wiki_comments (Confluence) — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-08-02' and metric_date le '2026-08-08'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - find: { metric_key: wiki_comments } - equal: { value: 5, median: 3, range_min: 1, range_max: 5 } - - - name: "wiki_comments (Confluence) — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-07-01' and metric_date le '2026-07-07'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/wiki_confluence_edits.test.yaml b/src/ingestion/tests/e2e/metrics/wiki_confluence_edits.test.yaml deleted file mode 100644 index da9dac324..000000000 --- a/src/ingestion/tests/e2e/metrics/wiki_confluence_edits.test.yaml +++ /dev/null @@ -1,66 +0,0 @@ -spec_version: 1 -description: > - Metric: wiki_edits (Confluence) — IC Bullet Wiki (…0041), #1440. SINGLE-WINDOW fixture. - Department of 5 Engineering members (alice…erin); requested IC member = erin. - One narrow custom window (2026-04-05 … 2026-04-11) holds ALL of this - fixture's rows (dated 2026-04-08); a second case probes an empty window. - - ISOLATION: silver.class_wiki_pages / class_wiki_engagement are incremental - delete+insert tables the e2e rig resets only at SESSION START — the per-test - ledger truncates only the bronze a fixture seeds, so a sibling fixture's silver - rows persist across tests. Every wiki fixture is therefore pinned to its OWN - disjoint date + window so a leaked foreign row falls OUTSIDE this window and - cannot perturb the stats. Per-person value = Σ(version_count−1) over the window = m → dept {1,2,3,4,5}. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - bronze_confluence.wiki_pages: - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/alice", page_id: cp-alice-1, unique_key: cf-alice-1, version_number: 2, created_at: "2026-04-08T00:00:00Z" } # vc-1 = 1 - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/bob", page_id: cp-bob-1, unique_key: cf-bob-1, version_number: 3, created_at: "2026-04-08T00:00:00Z" } # vc-1 = 2 - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/carol", page_id: cp-carol-1, unique_key: cf-carol-1, version_number: 4, created_at: "2026-04-08T00:00:00Z" } # vc-1 = 3 - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/dave", page_id: cp-dave-1, unique_key: cf-dave-1, version_number: 5, created_at: "2026-04-08T00:00:00Z" } # vc-1 = 4 - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/erin", page_id: cp-erin-1, unique_key: cf-erin-1, version_number: 6, created_at: "2026-04-08T00:00:00Z" } # vc-1 = 5 - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/erin", page_id: cp-erin-1, unique_key: cf-erin-1, version_number: 6, created_at: "2026-04-08T00:00:00Z" } # re-sync dup → must NOT double - -cases: - - name: "wiki_edits (Confluence) — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-04-05' and metric_date le '2026-04-11'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - find: { metric_key: wiki_edits } - equal: { value: 5, median: 3, range_min: 1, range_max: 5 } - - - name: "wiki_edits (Confluence) — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-07-01' and metric_date le '2026-07-07'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/wiki_confluence_pages_created.test.yaml b/src/ingestion/tests/e2e/metrics/wiki_confluence_pages_created.test.yaml deleted file mode 100644 index 41bcd187a..000000000 --- a/src/ingestion/tests/e2e/metrics/wiki_confluence_pages_created.test.yaml +++ /dev/null @@ -1,76 +0,0 @@ -spec_version: 1 -description: > - Metric: wiki_pages_created (Confluence) — IC Bullet Wiki (…0041), #1440. SINGLE-WINDOW fixture. - Department of 5 Engineering members (alice…erin); requested IC member = erin. - One narrow custom window (2026-02-01 … 2026-02-07) holds ALL of this - fixture's rows (dated 2026-02-04); a second case probes an empty window. - - ISOLATION: silver.class_wiki_pages / class_wiki_engagement are incremental - delete+insert tables the e2e rig resets only at SESSION START — the per-test - ledger truncates only the bronze a fixture seeds, so a sibling fixture's silver - rows persist across tests. Every wiki fixture is therefore pinned to its OWN - disjoint date + window so a leaked foreign row falls OUTSIDE this window and - cannot perturb the stats. Per-person value = count of pages authored in the window = m → dept {1,2,3,4,5}. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - bronze_confluence.wiki_pages: - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/alice", page_id: cp-alice-1, unique_key: cf-alice-1, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/bob", page_id: cp-bob-1, unique_key: cf-bob-1, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/bob", page_id: cp-bob-2, unique_key: cf-bob-2, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/carol", page_id: cp-carol-1, unique_key: cf-carol-1, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/carol", page_id: cp-carol-2, unique_key: cf-carol-2, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/carol", page_id: cp-carol-3, unique_key: cf-carol-3, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/dave", page_id: cp-dave-1, unique_key: cf-dave-1, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/dave", page_id: cp-dave-2, unique_key: cf-dave-2, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/dave", page_id: cp-dave-3, unique_key: cf-dave-3, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/dave", page_id: cp-dave-4, unique_key: cf-dave-4, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/erin", page_id: cp-erin-1, unique_key: cf-erin-1, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/erin", page_id: cp-erin-2, unique_key: cf-erin-2, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/erin", page_id: cp-erin-3, unique_key: cf-erin-3, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/erin", page_id: cp-erin-4, unique_key: cf-erin-4, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/erin", page_id: cp-erin-5, unique_key: cf-erin-5, version_number: 1, created_at: "2026-02-04T00:00:00Z" } - - { $ref: "templates/confluence_wiki_pages.yaml#/templates/erin", page_id: cp-erin-1, unique_key: cf-erin-1, version_number: 1, created_at: "2026-02-04T00:00:00Z" } # re-sync dup → count stays - -cases: - - name: "wiki_pages_created (Confluence) — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-02-01' and metric_date le '2026-02-07'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - find: { metric_key: wiki_pages_created } - equal: { value: 5, median: 3, range_min: 1, range_max: 5 } - - - name: "wiki_pages_created (Confluence) — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-07-01' and metric_date le '2026-07-07'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/wiki_edits.test.yaml b/src/ingestion/tests/e2e/metrics/wiki_edits.test.yaml new file mode 100644 index 000000000..e2d8cc4f6 --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/wiki_edits.test.yaml @@ -0,0 +1,67 @@ +spec_version: 1 +description: "Wiki edit sessions collapse rapid saves and count distinct pages edited." + +bronze: + bronze_bamboohr.employees: + - $ref: templates/people.yaml#/templates/alice + - $ref: templates/people.yaml#/templates/bob + - $ref: templates/people.yaml#/templates/carol + - $ref: templates/people.yaml#/templates/dave + - $ref: templates/people.yaml#/templates/erin + bronze_outline.wiki_page_versions: + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: alice-1, page_id: page-alice, revision_id: alice-1, author_id: alice, author_email: alice@example.com, created_at: "2026-05-06T08:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: alice-2, page_id: page-alice, revision_id: alice-2, author_id: alice, author_email: alice@example.com, created_at: "2026-05-06T08:10:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: bob-1, page_id: page-bob, revision_id: bob-1, author_id: bob, author_email: bob@example.com, created_at: "2026-05-06T08:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: bob-2, page_id: page-bob, revision_id: bob-2, author_id: bob, author_email: bob@example.com, created_at: "2026-05-06T09:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: carol-1, page_id: page-carol, revision_id: carol-1, author_id: carol, author_email: carol@example.com, created_at: "2026-05-06T08:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: carol-2, page_id: page-carol, revision_id: carol-2, author_id: carol, author_email: carol@example.com, created_at: "2026-05-06T09:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: carol-3, page_id: page-carol, revision_id: carol-3, author_id: carol, author_email: carol@example.com, created_at: "2026-05-06T10:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: dave-1, page_id: page-dave, revision_id: dave-1, author_id: dave, author_email: dave@example.com, created_at: "2026-05-06T08:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: dave-2, page_id: page-dave, revision_id: dave-2, author_id: dave, author_email: dave@example.com, created_at: "2026-05-06T09:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: dave-3, page_id: page-dave, revision_id: dave-3, author_id: dave, author_email: dave@example.com, created_at: "2026-05-06T10:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: dave-4, page_id: page-dave, revision_id: dave-4, author_id: dave, author_email: dave@example.com, created_at: "2026-05-06T11:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: erin-1, page_id: page-erin, revision_id: erin-1, author_id: erin, author_email: erin@example.com, created_at: "2026-05-06T08:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: erin-2, page_id: page-erin, revision_id: erin-2, author_id: erin, author_email: erin@example.com, created_at: "2026-05-06T09:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: erin-3, page_id: page-erin, revision_id: erin-3, author_id: erin, author_email: erin@example.com, created_at: "2026-05-06T10:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: erin-4, page_id: page-erin, revision_id: erin-4, author_id: erin, author_email: erin@example.com, created_at: "2026-05-06T11:00:00Z" } + - { $ref: templates/wiki_page_versions.yaml#/templates/revision, unique_key: erin-5, page_id: page-erin, revision_id: erin-5, author_id: erin, author_email: erin@example.com, created_at: "2026-05-06T12:00:00Z" } + +cases: + - name: Wiki edit sessions and pages edited + request: + url: /v1/metric-results + method: POST + body: + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-05-01", to: "2026-05-10" } + metrics: + - metric_key: wiki.edits + views: &wiki_edit_views + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + - { metric_key: wiki.pages_edited, views: *wiki_edit_views } + expect: + - assert: "status == 200" + - metric: wiki.edits + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: wiki.edits + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: wiki.edits + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-05-06' && double(p.value) == 5.0))" + - metric: wiki.pages_edited + view: period + find: { entity_id: erin@example.com } + equal: { value: 1 } + - metric: wiki.pages_edited + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 1, p25: 1, median: 1, p75: 1, min: 1, max: 1, n: 5 } + - metric: wiki.pages_edited + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, p.bucket_start == '2026-05-06' && double(p.value) == 1.0))" diff --git a/src/ingestion/tests/e2e/metrics/wiki_outline_active_authors.test.yaml b/src/ingestion/tests/e2e/metrics/wiki_outline_active_authors.test.yaml deleted file mode 100644 index c58e2416a..000000000 --- a/src/ingestion/tests/e2e/metrics/wiki_outline_active_authors.test.yaml +++ /dev/null @@ -1,66 +0,0 @@ -spec_version: 1 -description: > - Metric: wiki_active_authors (Outline) — IC Bullet Wiki (…0041), #1440. SINGLE-WINDOW fixture. - Department of 5 Engineering members (alice…erin); requested IC member = erin. - One narrow custom window (2026-11-01 … 2026-11-07) holds ALL of this - fixture's rows (dated 2026-11-04); a second case probes an empty window. - - ISOLATION: silver.class_wiki_pages / class_wiki_engagement are incremental - delete+insert tables the e2e rig resets only at SESSION START — the per-test - ledger truncates only the bronze a fixture seeds, so a sibling fixture's silver - rows persist across tests. Every wiki fixture is therefore pinned to its OWN - disjoint date + window so a leaked foreign row falls OUTSIDE this window and - cannot perturb the stats. All 5 members author a page in the window; value=1 (erin active), median=0 (rule), range_max = count of active authors = 5. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - bronze_outline.wiki_pages: - - { $ref: "templates/outline_wiki_pages.yaml#/templates/alice", page_id: pg-alice-1, unique_key: ol-alice-1, version_number: 1, created_at: "2026-11-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/bob", page_id: pg-bob-1, unique_key: ol-bob-1, version_number: 1, created_at: "2026-11-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/carol", page_id: pg-carol-1, unique_key: ol-carol-1, version_number: 1, created_at: "2026-11-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/dave", page_id: pg-dave-1, unique_key: ol-dave-1, version_number: 1, created_at: "2026-11-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: pg-erin-1, unique_key: ol-erin-1, version_number: 1, created_at: "2026-11-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: pg-erin-1, unique_key: ol-erin-1, version_number: 1, created_at: "2026-11-04T00:00:00Z" } # re-sync dup → active stays 1 - -cases: - - name: "wiki_active_authors (Outline) — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-11-01' and metric_date le '2026-11-07'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - find: { metric_key: wiki_active_authors } - equal: { value: 1, median: 0, range_min: 0, range_max: 5 } - - - name: "wiki_active_authors (Outline) — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-07-01' and metric_date le '2026-07-07'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/wiki_outline_comments.test.yaml b/src/ingestion/tests/e2e/metrics/wiki_outline_comments.test.yaml deleted file mode 100644 index 3b0a7e372..000000000 --- a/src/ingestion/tests/e2e/metrics/wiki_outline_comments.test.yaml +++ /dev/null @@ -1,82 +0,0 @@ -spec_version: 1 -description: > - Metric: wiki_comments (Outline) — IC Bullet Wiki (…0041), #1440. SINGLE-WINDOW fixture. - Department of 5 Engineering members (alice…erin); requested IC member = erin. - One narrow custom window (2026-09-06 … 2026-09-12) holds ALL of this - fixture's rows (dated 2026-09-09); a second case probes an empty window. - - ISOLATION: silver.class_wiki_pages / class_wiki_engagement are incremental - delete+insert tables the e2e rig resets only at SESSION START — the per-test - ledger truncates only the bronze a fixture seeds, so a sibling fixture's silver - rows persist across tests. Every wiki fixture is therefore pinned to its OWN - disjoint date + window so a leaked foreign row falls OUTSIDE this window and - cannot perturb the stats. Anchor pages are dated 2025-06-15 (outside ALL windows) for the engagement INNER JOIN; per-person value = Σ comments received in the window = m → dept {1,2,3,4,5}. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - bronze_outline.wiki_pages: - - { $ref: "templates/outline_wiki_pages.yaml#/templates/alice", page_id: pg-alice-1, unique_key: ol-alice-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) - - { $ref: "templates/outline_wiki_pages.yaml#/templates/bob", page_id: pg-bob-1, unique_key: ol-bob-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) - - { $ref: "templates/outline_wiki_pages.yaml#/templates/carol", page_id: pg-carol-1, unique_key: ol-carol-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) - - { $ref: "templates/outline_wiki_pages.yaml#/templates/dave", page_id: pg-dave-1, unique_key: ol-dave-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) - - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: pg-erin-1, unique_key: ol-erin-1, version_number: 1, created_at: "2025-06-15T00:00:00Z" } # anchor (outside all windows) - bronze_outline.wiki_comments: - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-alice-1, comment_id: calice-1, unique_key: oc-alice-1, created_at: "2026-09-09T01:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-bob-1, comment_id: cbob-1, unique_key: oc-bob-1, created_at: "2026-09-09T01:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-bob-1, comment_id: cbob-2, unique_key: oc-bob-2, created_at: "2026-09-09T02:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-carol-1, comment_id: ccarol-1, unique_key: oc-carol-1, created_at: "2026-09-09T01:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-carol-1, comment_id: ccarol-2, unique_key: oc-carol-2, created_at: "2026-09-09T02:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-carol-1, comment_id: ccarol-3, unique_key: oc-carol-3, created_at: "2026-09-09T03:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-dave-1, comment_id: cdave-1, unique_key: oc-dave-1, created_at: "2026-09-09T01:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-dave-1, comment_id: cdave-2, unique_key: oc-dave-2, created_at: "2026-09-09T02:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-dave-1, comment_id: cdave-3, unique_key: oc-dave-3, created_at: "2026-09-09T03:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-dave-1, comment_id: cdave-4, unique_key: oc-dave-4, created_at: "2026-09-09T04:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-erin-1, comment_id: cerin-1, unique_key: oc-erin-1, created_at: "2026-09-09T01:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-erin-1, comment_id: cerin-2, unique_key: oc-erin-2, created_at: "2026-09-09T02:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-erin-1, comment_id: cerin-3, unique_key: oc-erin-3, created_at: "2026-09-09T03:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-erin-1, comment_id: cerin-4, unique_key: oc-erin-4, created_at: "2026-09-09T04:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-erin-1, comment_id: cerin-5, unique_key: oc-erin-5, created_at: "2026-09-09T05:00:00Z" } - - { $ref: "templates/outline_wiki_comments.yaml#/templates/c", page_id: pg-erin-1, comment_id: cerin-1, unique_key: oc-erin-1, created_at: "2026-09-09T01:00:00Z" } # re-sync dup → not doubled - -cases: - - name: "wiki_comments (Outline) — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-09-06' and metric_date le '2026-09-12'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - find: { metric_key: wiki_comments } - equal: { value: 5, median: 3, range_min: 1, range_max: 5 } - - - name: "wiki_comments (Outline) — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-07-01' and metric_date le '2026-07-07'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/wiki_outline_edits.test.yaml b/src/ingestion/tests/e2e/metrics/wiki_outline_edits.test.yaml deleted file mode 100644 index 57b72a0ca..000000000 --- a/src/ingestion/tests/e2e/metrics/wiki_outline_edits.test.yaml +++ /dev/null @@ -1,66 +0,0 @@ -spec_version: 1 -description: > - Metric: wiki_edits (Outline) — IC Bullet Wiki (…0041), #1440. SINGLE-WINDOW fixture. - Department of 5 Engineering members (alice…erin); requested IC member = erin. - One narrow custom window (2026-05-03 … 2026-05-09) holds ALL of this - fixture's rows (dated 2026-05-06); a second case probes an empty window. - - ISOLATION: silver.class_wiki_pages / class_wiki_engagement are incremental - delete+insert tables the e2e rig resets only at SESSION START — the per-test - ledger truncates only the bronze a fixture seeds, so a sibling fixture's silver - rows persist across tests. Every wiki fixture is therefore pinned to its OWN - disjoint date + window so a leaked foreign row falls OUTSIDE this window and - cannot perturb the stats. Per-person value = Σ(version_count−1) over the window = m → dept {1,2,3,4,5}. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - bronze_outline.wiki_pages: - - { $ref: "templates/outline_wiki_pages.yaml#/templates/alice", page_id: pg-alice-1, unique_key: ol-alice-1, version_number: 2, created_at: "2026-05-06T00:00:00Z" } # vc-1 = 1 - - { $ref: "templates/outline_wiki_pages.yaml#/templates/bob", page_id: pg-bob-1, unique_key: ol-bob-1, version_number: 3, created_at: "2026-05-06T00:00:00Z" } # vc-1 = 2 - - { $ref: "templates/outline_wiki_pages.yaml#/templates/carol", page_id: pg-carol-1, unique_key: ol-carol-1, version_number: 4, created_at: "2026-05-06T00:00:00Z" } # vc-1 = 3 - - { $ref: "templates/outline_wiki_pages.yaml#/templates/dave", page_id: pg-dave-1, unique_key: ol-dave-1, version_number: 5, created_at: "2026-05-06T00:00:00Z" } # vc-1 = 4 - - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: pg-erin-1, unique_key: ol-erin-1, version_number: 6, created_at: "2026-05-06T00:00:00Z" } # vc-1 = 5 - - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: pg-erin-1, unique_key: ol-erin-1, version_number: 6, created_at: "2026-05-06T00:00:00Z" } # re-sync dup → must NOT double - -cases: - - name: "wiki_edits (Outline) — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-05-03' and metric_date le '2026-05-09'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - find: { metric_key: wiki_edits } - equal: { value: 5, median: 3, range_min: 1, range_max: 5 } - - - name: "wiki_edits (Outline) — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-07-01' and metric_date le '2026-07-07'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/wiki_outline_pages_created.test.yaml b/src/ingestion/tests/e2e/metrics/wiki_outline_pages_created.test.yaml deleted file mode 100644 index 104a66462..000000000 --- a/src/ingestion/tests/e2e/metrics/wiki_outline_pages_created.test.yaml +++ /dev/null @@ -1,76 +0,0 @@ -spec_version: 1 -description: > - Metric: wiki_pages_created (Outline) — IC Bullet Wiki (…0041), #1440. SINGLE-WINDOW fixture. - Department of 5 Engineering members (alice…erin); requested IC member = erin. - One narrow custom window (2026-03-01 … 2026-03-07) holds ALL of this - fixture's rows (dated 2026-03-04); a second case probes an empty window. - - ISOLATION: silver.class_wiki_pages / class_wiki_engagement are incremental - delete+insert tables the e2e rig resets only at SESSION START — the per-test - ledger truncates only the bronze a fixture seeds, so a sibling fixture's silver - rows persist across tests. Every wiki fixture is therefore pinned to its OWN - disjoint date + window so a leaked foreign row falls OUTSIDE this window and - cannot perturb the stats. Per-person value = count of pages authored in the window = m → dept {1,2,3,4,5}. - -bronze: - bronze_bamboohr.employees: - - $ref: templates/people.yaml#/templates/alice - - $ref: templates/people.yaml#/templates/bob - - $ref: templates/people.yaml#/templates/carol - - $ref: templates/people.yaml#/templates/dave - - $ref: templates/people.yaml#/templates/erin - bronze_outline.wiki_pages: - - { $ref: "templates/outline_wiki_pages.yaml#/templates/alice", page_id: pg-alice-1, unique_key: ol-alice-1, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/bob", page_id: pg-bob-1, unique_key: ol-bob-1, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/bob", page_id: pg-bob-2, unique_key: ol-bob-2, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/carol", page_id: pg-carol-1, unique_key: ol-carol-1, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/carol", page_id: pg-carol-2, unique_key: ol-carol-2, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/carol", page_id: pg-carol-3, unique_key: ol-carol-3, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/dave", page_id: pg-dave-1, unique_key: ol-dave-1, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/dave", page_id: pg-dave-2, unique_key: ol-dave-2, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/dave", page_id: pg-dave-3, unique_key: ol-dave-3, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/dave", page_id: pg-dave-4, unique_key: ol-dave-4, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: pg-erin-1, unique_key: ol-erin-1, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: pg-erin-2, unique_key: ol-erin-2, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: pg-erin-3, unique_key: ol-erin-3, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: pg-erin-4, unique_key: ol-erin-4, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: pg-erin-5, unique_key: ol-erin-5, version_number: 1, created_at: "2026-03-04T00:00:00Z" } - - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: pg-erin-1, unique_key: ol-erin-1, version_number: 1, created_at: "2026-03-04T00:00:00Z" } # re-sync dup → count stays - -cases: - - name: "wiki_pages_created (Outline) — custom scope (department of 5)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-03-01' and metric_date le '2026-03-07'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - find: { metric_key: wiki_pages_created } - equal: { value: 5, median: 3, range_min: 1, range_max: 5 } - - - name: "wiki_pages_created (Outline) — EMPTY window (no data → no items)" - request: - url: /v1/metrics/queries - method: POST - body: - queries: - - id: wiki - metric_id: 00000000-0000-0000-0001-000000000041 - $top: 50 - $filter: "person_id eq 'erin@example.com' and metric_date ge '2026-07-01' and metric_date le '2026-07-07'" - $orderby: metric_key - expect: - - assert: "status == 200" - - in: wiki - assert: "result.status == 'ok'" - - in: wiki - assert: "size(items) == 0" diff --git a/src/ingestion/tests/e2e/metrics/wiki_pages_created.test.yaml b/src/ingestion/tests/e2e/metrics/wiki_pages_created.test.yaml new file mode 100644 index 000000000..9375c24e5 --- /dev/null +++ b/src/ingestion/tests/e2e/metrics/wiki_pages_created.test.yaml @@ -0,0 +1,66 @@ +spec_version: 1 +description: > + Metric: wiki_pages_created (Confluence) — IC Bullet Wiki (…0041), #1440. SINGLE-WINDOW fixture. + Department of 5 Engineering members (alice…erin); requested IC member = erin. + One narrow custom window (2026-02-01 … 2026-02-07) holds ALL of this + fixture's rows (dated 2026-02-04); a second case probes an empty window. + + ISOLATION: silver.class_wiki_pages / class_wiki_engagement are incremental + delete+insert tables the e2e rig resets only at SESSION START — the per-test + ledger truncates only the bronze a fixture seeds, so a sibling fixture's silver + rows persist across tests. Every wiki fixture is therefore pinned to its OWN + disjoint date + window so a leaked foreign row falls OUTSIDE this window and + cannot perturb the stats. Per-person value = count of pages authored in the window = m → dept {1,2,3,4,5}. + +bronze: + bronze_bamboohr.employees: + - $ref: templates/people.yaml#/templates/alice + - $ref: templates/people.yaml#/templates/bob + - $ref: templates/people.yaml#/templates/carol + - $ref: templates/people.yaml#/templates/dave + - $ref: templates/people.yaml#/templates/erin + bronze_outline.wiki_pages: + - { $ref: "templates/outline_wiki_pages.yaml#/templates/alice", page_id: cp-alice-1, unique_key: cf-alice-1, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/bob", page_id: cp-bob-1, unique_key: cf-bob-1, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/bob", page_id: cp-bob-2, unique_key: cf-bob-2, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/carol", page_id: cp-carol-1, unique_key: cf-carol-1, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/carol", page_id: cp-carol-2, unique_key: cf-carol-2, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/carol", page_id: cp-carol-3, unique_key: cf-carol-3, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/dave", page_id: cp-dave-1, unique_key: cf-dave-1, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/dave", page_id: cp-dave-2, unique_key: cf-dave-2, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/dave", page_id: cp-dave-3, unique_key: cf-dave-3, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/dave", page_id: cp-dave-4, unique_key: cf-dave-4, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: cp-erin-1, unique_key: cf-erin-1, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: cp-erin-2, unique_key: cf-erin-2, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: cp-erin-3, unique_key: cf-erin-3, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: cp-erin-4, unique_key: cf-erin-4, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: cp-erin-5, unique_key: cf-erin-5, version_number: 1, created_at: "2026-02-04T00:00:00Z" } + - { $ref: "templates/outline_wiki_pages.yaml#/templates/erin", page_id: cp-erin-1, unique_key: cf-erin-1, version_number: 1, created_at: "2026-02-04T00:00:00Z" } # re-sync dup → count stays + +cases: + - name: wiki.pages_created + request: + url: /v1/metric-results + method: POST + body: + entity: { type: person, ids: [erin@example.com] } + period: { from: "2026-02-01", to: "2026-02-07" } + metrics: + - metric_key: wiki.pages_created + views: + - { view: period } + - { view: peer } + - { view: timeseries, bucket: day } + expect: + - assert: "status == 200" + - metric: wiki.pages_created + view: period + find: { entity_id: erin@example.com } + equal: { value: 5 } + - metric: wiki.pages_created + view: peer + find: { entity_id: erin@example.com } + equal: { target_value: 5, p25: 2, median: 3, p75: 4, min: 1, max: 5, n: 5 } + - metric: wiki.pages_created + view: timeseries + assert: "items.exists(s, s.entity_id == 'erin@example.com' && s.points.exists(p, double(p.value) == 5.0))"