"""Tests for web POST actions, untested fragments, CSRF protection, and utility functions."""

from __future__ import annotations

from datetime import datetime, timedelta, timezone

import pytest
from httpx import AsyncClient, ASGITransport

from project_hub.web.app import create_app, _apply_vuln_filter


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_state(**overrides):
    base = {
        "ready": True, "repos": [], "mrs": [], "pipelines": [],
        "vulns": [], "issues": [], "events": [], "last_refresh": None,
        "sync_status": "idle", "usernames": {},
        "pinned": [], "repo_order": [], "excluded": [],
        "forges": {},
    }
    base.update(overrides)
    return base


HX = {"HX-Request": "true"}


def _make_app(**kwargs):
    """Create app with sensible defaults; kwargs override create_app params."""
    defaults = dict(
        get_state=lambda: _make_state(),
        trigger_refresh=None,
        dismiss_event=None,
        dismiss_all_events=None,
        fetch_repo=None,
        pull_repo=None,
        pull_all=None,
        checkout_default_all=None,
        checkout_default_one=None,
        get_pipeline_jobs=None,
        pin_repo=None,
        unpin_repo=None,
        save_repo_order=None,
        exclude_repo=None,
        include_repo=None,
        rescan=None,
    )
    defaults.update(kwargs)
    return create_app(**defaults)


async def _client(app) -> AsyncClient:
    return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")


# ---------------------------------------------------------------------------
# 1. /action/fetch/{repo_name}
# ---------------------------------------------------------------------------

async def test_fetch_triggers_callback_and_returns_204():
    called = []

    async def mock_fetch(name):
        called.append(name)

    app = _make_app(fetch_repo=mock_fetch)
    async with await _client(app) as c:
        resp = await c.post("/action/fetch/my-repo", headers=HX)
    assert resp.status_code == 204
    assert called == ["my-repo"]


async def test_fetch_invalid_repo_name_returns_400():
    app = _make_app()
    async with await _client(app) as c:
        resp = await c.post("/action/fetch/repo%20name%3B%20rm", headers=HX)
    assert resp.status_code == 400


async def test_fetch_no_callback_still_204():
    app = _make_app(fetch_repo=None)
    async with await _client(app) as c:
        resp = await c.post("/action/fetch/some-repo", headers=HX)
    assert resp.status_code == 204


# ---------------------------------------------------------------------------
# 2. /action/pull/{repo_name}
# ---------------------------------------------------------------------------

async def test_pull_triggers_callback_and_returns_toast():
    async def mock_pull(name):
        return {"status": "updated"}

    app = _make_app(pull_repo=mock_pull)
    async with await _client(app) as c:
        resp = await c.post("/action/pull/my-repo", headers=HX)
    assert resp.status_code == 200
    assert "pulled successfully" in resp.text


async def test_pull_already_up_to_date():
    async def mock_pull(name):
        return {"status": "already_up_to_date"}

    app = _make_app(pull_repo=mock_pull)
    async with await _client(app) as c:
        resp = await c.post("/action/pull/my-repo", headers=HX)
    assert resp.status_code == 200
    assert "already up to date" in resp.text


async def test_pull_skipped_dirty():
    async def mock_pull(name):
        return {"status": "skipped_dirty"}

    app = _make_app(pull_repo=mock_pull)
    async with await _client(app) as c:
        resp = await c.post("/action/pull/my-repo", headers=HX)
    assert resp.status_code == 200
    assert "dirty working tree" in resp.text


async def test_pull_no_callback_returns_error_toast():
    app = _make_app(pull_repo=None)
    async with await _client(app) as c:
        resp = await c.post("/action/pull/my-repo", headers=HX)
    assert resp.status_code == 200
    assert "Pull not available" in resp.text


async def test_pull_invalid_repo_name_returns_400():
    app = _make_app()
    async with await _client(app) as c:
        resp = await c.post("/action/pull/repo%20name%3B%20rm", headers=HX)
    assert resp.status_code == 400


# ---------------------------------------------------------------------------
# 3. /action/pull-all
# ---------------------------------------------------------------------------

async def test_pull_all_triggers_callback():
    async def mock_pull_all():
        return [
            {"repo_name": "a", "status": "updated"},
            {"repo_name": "b", "status": "already_up_to_date"},
        ]

    app = _make_app(pull_all=mock_pull_all)
    async with await _client(app) as c:
        resp = await c.post("/action/pull-all", headers=HX)
    assert resp.status_code == 200
    assert "1 pulled" in resp.text
    assert "1 skipped" in resp.text


async def test_pull_all_with_errors():
    async def mock_pull_all():
        return [
            {"repo_name": "a", "status": "error"},
            {"repo_name": "b", "status": "updated"},
        ]

    app = _make_app(pull_all=mock_pull_all)
    async with await _client(app) as c:
        resp = await c.post("/action/pull-all", headers=HX)
    assert resp.status_code == 200
    assert "1 errors" in resp.text


async def test_pull_all_no_callback():
    app = _make_app(pull_all=None)
    async with await _client(app) as c:
        resp = await c.post("/action/pull-all", headers=HX)
    assert resp.status_code == 200
    assert "Pull not available" in resp.text


# ---------------------------------------------------------------------------
# 4. /action/checkout-default
# ---------------------------------------------------------------------------

async def test_checkout_default_all_triggers_callback():
    async def mock_checkout():
        return [{"repo_name": "a", "status": "ok", "branch": "main"}]

    app = _make_app(checkout_default_all=mock_checkout)
    async with await _client(app) as c:
        resp = await c.post("/action/checkout-default", headers=HX)
    assert resp.status_code == 200
    assert "1 switched" in resp.text


async def test_checkout_default_all_no_stale():
    async def mock_checkout():
        return []

    app = _make_app(checkout_default_all=mock_checkout)
    async with await _client(app) as c:
        resp = await c.post("/action/checkout-default", headers=HX)
    assert resp.status_code == 200
    assert "No stale branches" in resp.text


async def test_checkout_default_all_no_callback():
    app = _make_app(checkout_default_all=None)
    async with await _client(app) as c:
        resp = await c.post("/action/checkout-default", headers=HX)
    assert resp.status_code == 200
    assert "Not available" in resp.text


async def test_checkout_default_all_mixed_results():
    async def mock_checkout():
        return [
            {"repo_name": "a", "status": "ok", "branch": "main"},
            {"repo_name": "b", "status": "error", "message": "dirty"},
        ]

    app = _make_app(checkout_default_all=mock_checkout)
    async with await _client(app) as c:
        resp = await c.post("/action/checkout-default", headers=HX)
    assert resp.status_code == 200
    assert "1 switched" in resp.text
    assert "1 failed" in resp.text


# ---------------------------------------------------------------------------
# 5. /action/checkout-default/{repo_name}
# ---------------------------------------------------------------------------

async def test_checkout_default_one_triggers_callback():
    async def mock_checkout(name):
        return {"status": "ok", "branch": "main"}

    app = _make_app(checkout_default_one=mock_checkout)
    async with await _client(app) as c:
        resp = await c.post("/action/checkout-default/my-repo", headers=HX)
    assert resp.status_code == 200
    assert "Switched to main" in resp.text


async def test_checkout_default_one_error():
    async def mock_checkout(name):
        return {"status": "error", "message": "dirty worktree"}

    app = _make_app(checkout_default_one=mock_checkout)
    async with await _client(app) as c:
        resp = await c.post("/action/checkout-default/my-repo", headers=HX)
    assert resp.status_code == 200
    assert "dirty worktree" in resp.text


async def test_checkout_default_one_invalid_repo_returns_400():
    app = _make_app()
    async with await _client(app) as c:
        resp = await c.post("/action/checkout-default/repo%20name%3B%20rm", headers=HX)
    assert resp.status_code == 400


async def test_checkout_default_one_no_callback():
    app = _make_app(checkout_default_one=None)
    async with await _client(app) as c:
        resp = await c.post("/action/checkout-default/my-repo", headers=HX)
    assert resp.status_code == 200
    assert "Not available" in resp.text


# ---------------------------------------------------------------------------
# 6. /action/dismiss-all-events
# ---------------------------------------------------------------------------

async def test_dismiss_all_events_triggers_callback():
    called = []

    async def mock_dismiss_all():
        called.append(True)

    app = _make_app(dismiss_all_events=mock_dismiss_all)
    async with await _client(app) as c:
        resp = await c.post("/action/dismiss-all-events", headers=HX)
    assert resp.status_code == 200
    assert called == [True]
    assert "All events marked as seen" in resp.text


async def test_dismiss_all_events_no_callback():
    app = _make_app(dismiss_all_events=None)
    async with await _client(app) as c:
        resp = await c.post("/action/dismiss-all-events", headers=HX)
    assert resp.status_code == 200


# ---------------------------------------------------------------------------
# 7. /action/unpin/{repo_name}
# ---------------------------------------------------------------------------

async def test_unpin_triggers_callback_and_returns_204():
    called = []

    async def mock_unpin(name):
        called.append(name)

    app = _make_app(unpin_repo=mock_unpin)
    async with await _client(app) as c:
        resp = await c.post("/action/unpin/my-repo", headers=HX)
    assert resp.status_code == 204
    assert called == ["my-repo"]


async def test_unpin_invalid_repo_returns_400():
    app = _make_app()
    async with await _client(app) as c:
        resp = await c.post("/action/unpin/repo%20name%3B%20rm", headers=HX)
    assert resp.status_code == 400


# ---------------------------------------------------------------------------
# 8. /action/include/{repo_name}
# ---------------------------------------------------------------------------

async def test_include_triggers_callback_and_returns_204():
    called = []

    async def mock_include(name):
        called.append(name)

    app = _make_app(include_repo=mock_include)
    async with await _client(app) as c:
        resp = await c.post("/action/include/my-repo", headers=HX)
    assert resp.status_code == 204
    assert called == ["my-repo"]


async def test_include_invalid_repo_returns_400():
    app = _make_app()
    async with await _client(app) as c:
        resp = await c.post("/action/include/repo%20name%3B%20rm", headers=HX)
    assert resp.status_code == 400


# ---------------------------------------------------------------------------
# 9. /action/reorder
# ---------------------------------------------------------------------------

async def test_reorder_triggers_callback():
    called = []

    async def mock_reorder(order):
        called.append(order)

    app = _make_app(save_repo_order=mock_reorder)
    async with await _client(app) as c:
        resp = await c.post(
            "/action/reorder",
            json={"order": ["repo-b", "repo-a"]},
            headers=HX,
        )
    assert resp.status_code == 204
    assert called == [["repo-b", "repo-a"]]


async def test_reorder_no_callback():
    app = _make_app(save_repo_order=None)
    async with await _client(app) as c:
        resp = await c.post(
            "/action/reorder",
            json={"order": ["repo-b"]},
            headers=HX,
        )
    assert resp.status_code == 204


# ---------------------------------------------------------------------------
# 10. CSRF protection
# ---------------------------------------------------------------------------

async def test_post_without_hx_request_returns_403():
    app = _make_app()
    async with await _client(app) as c:
        resp = await c.post("/action/sync")
    assert resp.status_code == 403


async def test_post_with_hx_request_succeeds():
    app = _make_app()
    async with await _client(app) as c:
        resp = await c.post("/action/sync", headers=HX)
    assert resp.status_code == 204


async def test_get_requests_work_without_hx_request():
    app = _make_app()
    async with await _client(app) as c:
        resp = await c.get("/")
    assert resp.status_code == 200


# ---------------------------------------------------------------------------
# 11. Repo name validation
# ---------------------------------------------------------------------------

async def test_route_with_path_traversal_returns_400():
    """Repo names with shell metacharacters are rejected."""
    app = _make_app()
    async with await _client(app) as c:
        resp = await c.post("/action/fetch/repo%24(whoami)", headers=HX)
    assert resp.status_code == 400


async def test_route_with_normal_repo_name_succeeds():
    called = []

    async def mock_fetch(name):
        called.append(name)

    app = _make_app(fetch_repo=mock_fetch)
    async with await _client(app) as c:
        resp = await c.post("/action/fetch/my-awesome-repo", headers=HX)
    assert resp.status_code == 204
    assert called == ["my-awesome-repo"]


async def test_route_with_special_chars_in_pin_returns_400():
    """Pin with special characters in repo name is rejected."""
    app = _make_app()
    async with await _client(app) as c:
        resp = await c.post("/action/pin/repo%3Brm%20-rf", headers=HX)
    assert resp.status_code == 400


# ---------------------------------------------------------------------------
# 12. /fragment/pipeline-jobs/{repo_name}/{pipeline_id}
# ---------------------------------------------------------------------------

async def test_pipeline_jobs_fragment_returns_html():
    async def mock_jobs(repo_name, pipeline_id):
        return [
            {"stage": "test", "name": "unit-test", "status": "success", "web_url": "https://gl.com/jobs/1"},
            {"stage": "deploy", "name": "deploy-prod", "status": "failed", "web_url": "https://gl.com/jobs/2"},
        ]

    app = _make_app(get_pipeline_jobs=mock_jobs)
    async with await _client(app) as c:
        resp = await c.get("/fragment/pipeline-jobs/my-repo/42")
    assert resp.status_code == 200
    assert "unit-test" in resp.text
    assert "deploy-prod" in resp.text
    assert "success" in resp.text
    assert "failed" in resp.text


async def test_pipeline_jobs_fragment_no_callback():
    app = _make_app(get_pipeline_jobs=None)
    async with await _client(app) as c:
        resp = await c.get("/fragment/pipeline-jobs/my-repo/42")
    assert resp.status_code == 200
    assert "No jobs" in resp.text


async def test_pipeline_jobs_invalid_repo_returns_400():
    app = _make_app()
    async with await _client(app) as c:
        resp = await c.get("/fragment/pipeline-jobs/repo%20name%3B%20rm/42")
    assert resp.status_code == 400


# ---------------------------------------------------------------------------
# 13. /fragment/repo/{name}/mrs
# ---------------------------------------------------------------------------

async def test_repo_mrs_fragment():
    state = _make_state(mrs=[
        {"repo_name": "my-repo", "iid": 1, "title": "Fix bug", "author": "alice",
         "state": "opened", "source_branch": "fix/1", "target_branch": "main",
         "web_url": "https://gl.com/mr/1", "user_notes_count": 3, "approved": False},
        {"repo_name": "other-repo", "iid": 2, "title": "Other MR", "author": "bob",
         "state": "opened", "source_branch": "feat/2", "target_branch": "main",
         "web_url": "https://gl.com/mr/2", "user_notes_count": 0, "approved": True},
    ])
    app = _make_app(get_state=lambda: state)
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/my-repo/mrs")
    assert resp.status_code == 200
    assert "Fix bug" in resp.text
    # Should not contain the MR from other-repo
    assert "Other MR" not in resp.text


async def test_repo_mrs_fragment_empty():
    app = _make_app(get_state=lambda: _make_state())
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/my-repo/mrs")
    assert resp.status_code == 200
    assert "No open MRs" in resp.text


# ---------------------------------------------------------------------------
# 14. /fragment/repo/{name}/issues
# ---------------------------------------------------------------------------

async def test_repo_issues_fragment():
    state = _make_state(issues=[
        {"repo_name": "my-repo", "iid": 10, "title": "Login broken", "author": "alice",
         "state": "opened", "web_url": "https://gl.com/issues/10",
         "user_notes_count": 1, "labels": ["bug"], "assignees": [], "participants": []},
        {"repo_name": "other-repo", "iid": 11, "title": "Other issue", "author": "bob",
         "state": "opened", "web_url": "https://gl.com/issues/11",
         "user_notes_count": 0, "labels": [], "assignees": [], "participants": []},
    ])
    app = _make_app(get_state=lambda: state)
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/my-repo/issues")
    assert resp.status_code == 200
    assert "Login broken" in resp.text
    assert "Other issue" not in resp.text


async def test_repo_issues_fragment_empty():
    app = _make_app(get_state=lambda: _make_state())
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/my-repo/issues")
    assert resp.status_code == 200
    assert "No open issues" in resp.text


# ---------------------------------------------------------------------------
# 15. /fragment/repo/{name}/pipelines
# ---------------------------------------------------------------------------

async def test_repo_pipelines_fragment():
    state = _make_state(
        pipelines=[
            {"repo_name": "my-repo", "id": 100, "status": "success", "ref": "main",
             "web_url": "https://gl.com/pipelines/100", "created_at": "2024-01-02T00:00:00Z"},
            {"repo_name": "my-repo", "id": 99, "status": "failed", "ref": "main",
             "web_url": "https://gl.com/pipelines/99", "created_at": "2024-01-01T00:00:00Z"},
            {"repo_name": "other-repo", "id": 200, "status": "success", "ref": "main",
             "web_url": "https://gl.com/pipelines/200", "created_at": "2024-01-01T00:00:00Z"},
        ],
        repos=[
            {"name": "my-repo", "branch": "main", "remote_url": "https://gitlab.com/group/my-repo.git"},
        ],
    )
    app = _make_app(get_state=lambda: state)
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/my-repo/pipelines")
    assert resp.status_code == 200
    assert "success" in resp.text
    assert "failed" in resp.text


async def test_repo_pipelines_fragment_empty():
    app = _make_app(get_state=lambda: _make_state())
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/my-repo/pipelines")
    assert resp.status_code == 200
    assert "No pipelines" in resp.text


# ---------------------------------------------------------------------------
# 16. /fragment/repo/{name}/vulns
# ---------------------------------------------------------------------------

async def test_repo_vulns_fragment():
    state = _make_state(
        vulns=[
            {"repo_name": "my-repo", "id": 1, "name": "CVE-2024-001", "severity": "critical",
             "state": "detected", "source": "sast", "web_url": "https://gl.com/vulns/1",
             "fixed_in_branch": False, "location": "src/main.py"},
            {"repo_name": "other-repo", "id": 2, "name": "CVE-2024-002", "severity": "low",
             "state": "detected", "source": "dast", "web_url": "https://gl.com/vulns/2",
             "fixed_in_branch": False, "location": ""},
        ],
        repos=[
            {"name": "my-repo", "branch": "main", "remote_url": "https://gitlab.com/group/my-repo.git"},
        ],
    )
    app = _make_app(get_state=lambda: state)
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/my-repo/vulns")
    assert resp.status_code == 200
    assert "CVE-2024-001" in resp.text
    assert "CVE-2024-002" not in resp.text


async def test_repo_vulns_fragment_empty():
    app = _make_app(get_state=lambda: _make_state())
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/my-repo/vulns")
    assert resp.status_code == 200
    assert "No vulnerabilities" in resp.text


async def test_repo_vulns_fragment_with_filter():
    state = _make_state(
        vulns=[
            {"repo_name": "my-repo", "id": 1, "name": "CVE-ACTIVE", "severity": "critical",
             "state": "detected", "source": "sast", "web_url": "", "fixed_in_branch": False, "location": ""},
            {"repo_name": "my-repo", "id": 2, "name": "CVE-RESOLVED", "severity": "high",
             "state": "resolved", "source": "sast", "web_url": "", "fixed_in_branch": False, "location": ""},
        ],
        repos=[{"name": "my-repo", "branch": "main", "remote_url": ""}],
    )
    app = _make_app(get_state=lambda: state)
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/my-repo/vulns?filter=active")
    assert resp.status_code == 200
    assert "CVE-ACTIVE" in resp.text
    assert "CVE-RESOLVED" not in resp.text


# ---------------------------------------------------------------------------
# 17. _apply_vuln_filter function
# ---------------------------------------------------------------------------

def test_vuln_filter_active():
    vulns = [
        {"state": "detected", "severity": "high", "fixed_in_branch": False},
        {"state": "resolved", "severity": "high", "fixed_in_branch": False},
        {"state": "dismissed", "severity": "low", "fixed_in_branch": False},
    ]
    result = _apply_vuln_filter(vulns, "active")
    assert len(result) == 1
    assert result[0]["state"] == "detected"


def test_vuln_filter_hide_fixed():
    vulns = [
        {"state": "detected", "severity": "high", "fixed_in_branch": True},
        {"state": "detected", "severity": "high", "fixed_in_branch": False},
    ]
    result = _apply_vuln_filter(vulns, "hide-fixed")
    assert len(result) == 1
    assert result[0]["fixed_in_branch"] is False


def test_vuln_filter_critical():
    vulns = [
        {"state": "detected", "severity": "critical", "fixed_in_branch": False},
        {"state": "detected", "severity": "high", "fixed_in_branch": False},
        {"state": "detected", "severity": "low", "fixed_in_branch": False},
    ]
    result = _apply_vuln_filter(vulns, "critical")
    assert len(result) == 1
    assert result[0]["severity"] == "critical"


def test_vuln_filter_combined():
    vulns = [
        {"state": "detected", "severity": "critical", "fixed_in_branch": False},
        {"state": "detected", "severity": "critical", "fixed_in_branch": True},
        {"state": "resolved", "severity": "critical", "fixed_in_branch": False},
        {"state": "detected", "severity": "high", "fixed_in_branch": False},
    ]
    result = _apply_vuln_filter(vulns, "active,hide-fixed,critical")
    assert len(result) == 1
    assert result[0]["state"] == "detected"
    assert result[0]["severity"] == "critical"
    assert result[0]["fixed_in_branch"] is False


def test_vuln_filter_empty_string():
    vulns = [
        {"state": "detected", "severity": "high", "fixed_in_branch": False},
        {"state": "resolved", "severity": "low", "fixed_in_branch": True},
    ]
    result = _apply_vuln_filter(vulns, "")
    assert len(result) == 2


def test_vuln_filter_none():
    vulns = [{"state": "detected", "severity": "high", "fixed_in_branch": False}]
    result = _apply_vuln_filter(vulns, None)
    assert len(result) == 1


# ---------------------------------------------------------------------------
# 18. _timeago filter
# ---------------------------------------------------------------------------

async def test_timeago_just_now():
    """30 seconds ago should show 'just now'."""
    app = _make_app()
    # Access the Jinja2 env through the app to get the filter
    # We test via the template engine by rendering a template fragment
    from project_hub.web.app import create_app
    # Simpler: directly test the filter logic by extracting it
    dt = datetime.now(timezone.utc) - timedelta(seconds=30)
    iso = dt.isoformat()

    state = _make_state(pipelines=[
        {"repo_name": "r", "id": 1, "status": "success", "ref": "main",
         "web_url": "", "created_at": iso},
    ], repos=[{"name": "r", "branch": "main", "remote_url": ""}])
    app = _make_app(get_state=lambda: state)
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/r/pipelines")
    assert resp.status_code == 200
    assert "just now" in resp.text


async def test_timeago_minutes():
    """5 minutes ago should show '5m ago'."""
    dt = datetime.now(timezone.utc) - timedelta(minutes=5)
    iso = dt.isoformat()

    state = _make_state(pipelines=[
        {"repo_name": "r", "id": 1, "status": "success", "ref": "main",
         "web_url": "", "created_at": iso},
    ], repos=[{"name": "r", "branch": "main", "remote_url": ""}])
    app = _make_app(get_state=lambda: state)
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/r/pipelines")
    assert resp.status_code == 200
    assert "5m ago" in resp.text


async def test_timeago_hours():
    """2 hours ago should show '2h ago'."""
    dt = datetime.now(timezone.utc) - timedelta(hours=2)
    iso = dt.isoformat()

    state = _make_state(pipelines=[
        {"repo_name": "r", "id": 1, "status": "success", "ref": "main",
         "web_url": "", "created_at": iso},
    ], repos=[{"name": "r", "branch": "main", "remote_url": ""}])
    app = _make_app(get_state=lambda: state)
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/r/pipelines")
    assert resp.status_code == 200
    assert "2h ago" in resp.text


async def test_timeago_days():
    """3 days ago should show '3d ago'."""
    dt = datetime.now(timezone.utc) - timedelta(days=3)
    iso = dt.isoformat()

    state = _make_state(pipelines=[
        {"repo_name": "r", "id": 1, "status": "success", "ref": "main",
         "web_url": "", "created_at": iso},
    ], repos=[{"name": "r", "branch": "main", "remote_url": ""}])
    app = _make_app(get_state=lambda: state)
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/r/pipelines")
    assert resp.status_code == 200
    assert "3d ago" in resp.text


async def test_timeago_future():
    """Future date should show 'just now'."""
    dt = datetime.now(timezone.utc) + timedelta(hours=1)
    iso = dt.isoformat()

    state = _make_state(pipelines=[
        {"repo_name": "r", "id": 1, "status": "success", "ref": "main",
         "web_url": "", "created_at": iso},
    ], repos=[{"name": "r", "branch": "main", "remote_url": ""}])
    app = _make_app(get_state=lambda: state)
    async with await _client(app) as c:
        resp = await c.get("/fragment/repo/r/pipelines")
    assert resp.status_code == 200
    assert "just now" in resp.text
