From abf8654b52fb2757bcfbac9e7d38bdcbfe4a3b20 Mon Sep 17 00:00:00 2001 From: Hyemin Lee Date: Wed, 17 Jun 2026 14:04:54 +0900 Subject: [PATCH] feat: repo list search, header sort, pagination (stage 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list_repos: Pulp next 페이지 끝까지 순회 (첫-페이지-only 누락 수정) - BFF 메모리 검색(이름)·정렬(이름/검증/버전/패키지/동기화)·페이지네이션(20/페이지) - GET /repos/table 조각: 검색창은 표 밖(포커스 유지), 정렬헤더/페이저는 표 안, hx-include 로 검색어·정렬상태 상호 전송 - 폰트 재조정: 데이터 셀 14px, 헤더 13px, 배지/액션버튼 14px (동기화 버튼 대비 균형) - 버전 페이지 제목↔검증상세 마진, 데모 저장소 26개로 확장 Co-Authored-By: Claude Opus 4.8 (1M context) --- PLAN.md | 14 ++ app/demo.py | 204 +++++++++++++------------ app/pulp_client.py | 11 +- app/routes/dashboard.py | 90 +++++++++-- app/static/app.css | 55 ++++++- app/templates/partials/repo_list.html | 46 ++---- app/templates/partials/table_wrap.html | 75 +++++++++ app/views.py | 19 +++ tests/test_dashboard.py | 69 +++++++++ tests/test_pulp_client.py | 19 +++ tests/test_views.py | 33 ++++ 11 files changed, 477 insertions(+), 158 deletions(-) create mode 100644 app/templates/partials/table_wrap.html diff --git a/PLAN.md b/PLAN.md index 3528310..b9ca81a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -154,6 +154,20 @@ distribution PATCH → task 폴링 → 배지 갱신 → 감사 로그 기록. --- +## [x] 단계 6 — 목록 컨트롤: 검색 · 헤더 정렬 · 페이지네이션 + +**목표:** 저장소가 많아져도 찾고/정렬하고/넘겨볼 수 있게. + +- `pulp_client.list_repos`: Pulp `next` 페이지를 끝까지 순회(첫-페이지-only 버그 수정). +- BFF에서 전체 enrich 후 메모리 검색(이름)·정렬(이름/검증/버전/패키지/동기화)·페이지네이션 + (수십 개 규모 가정; 요약 카드는 항상 전체 인벤토리 기준). +- `GET /repos/table` 조각 추가 — 검색창은 표 바깥(포커스 유지), 정렬 헤더/페이저는 표 안. + 검색은 `[data-state]`로 정렬 상태를, 헤더/페이저는 `#repo-search`로 검색어를 함께 전송. + +**검증:** 이름 검색 필터, 헤더 클릭 정렬(방향 토글·표시), 20개 초과 시 페이저. + +--- + ## 교차 관심사 (전 단계 공통) - **에러 처리:** httpx 타임아웃/연결 실패/4xx·5xx를 잡아 화면에 사람이 읽을 수 있는 diff --git a/app/demo.py b/app/demo.py index 226350f..5f279e2 100644 --- a/app/demo.py +++ b/app/demo.py @@ -1,62 +1,88 @@ -"""임시 데모용 가짜 Pulp 클라이언트 — 성공 대시보드 미리보기. +"""임시 데모용 가짜 Pulp 클라이언트 — 실제 Pulp 없이 화면 확인. -PULP_DEMO=true 일 때만 사용된다. 실제 Pulp 서버 없이 화면을 확인하기 위한 것. -정식 기능과 무관하므로 단계 5(또는 실제 Pulp 연동 시) 제거 가능. +PULP_DEMO=true 일 때만 사용. 검색·정렬·페이지네이션을 눈으로 보도록 저장소를 여러 개 생성한다. +정식 기능과 무관하므로 실제 Pulp 연동 시 제거 가능. """ from __future__ import annotations from typing import Any -_REPOS: list[dict[str, Any]] = [ - { - "pulp_href": "/pulp/api/v3/repositories/rpm/rpm/0001/", - "name": "rocky9-baseos", - "latest_version_href": "/pulp/api/v3/repositories/rpm/rpm/0001/versions/3/", - "repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1}, - }, - { - "pulp_href": "/pulp/api/v3/repositories/rpm/rpm/0002/", - "name": "rocky9-appstream", - "latest_version_href": "/pulp/api/v3/repositories/rpm/rpm/0002/versions/2/", - "repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1}, - }, - { - "pulp_href": "/pulp/api/v3/repositories/rpm/rpm/0003/", - "name": "ubuntu2204-main", - "latest_version_href": "/pulp/api/v3/repositories/rpm/rpm/0003/versions/5/", - "repo_config": {"gpgcheck": 1, "repo-gpgcheck": 0}, # 부분 검증 - }, - { - "pulp_href": "/pulp/api/v3/repositories/rpm/rpm/0004/", - "name": "internal-tools", - "latest_version_href": "/pulp/api/v3/repositories/rpm/rpm/0004/versions/1/", - "repo_config": {}, # 미설정 - }, +_NAMES = [ + "rocky9-baseos", + "rocky9-appstream", + "rocky9-extras", + "rocky8-baseos", + "rocky8-appstream", + "almalinux9-baseos", + "almalinux9-appstream", + "ubuntu2204-main", + "ubuntu2204-updates", + "ubuntu2204-security", + "ubuntu2004-main", + "debian12-main", + "debian12-updates", + "epel9", + "epel8", + "centos-stream9", + "docker-ce", + "kubernetes", + "postgresql16", + "nginx-stable", + "grafana", + "elastic-8", + "hashicorp", + "internal-tools", + "internal-agents", + "monitoring-exporters", ] -_VERSIONS: dict[str, dict[str, Any]] = { - "/pulp/api/v3/repositories/rpm/rpm/0001/versions/3/": { - "pulp_created": "2026-06-15T02:11:00Z", - "content_summary": {"present": {"rpm.package": {"count": 4821}}}, - }, - "/pulp/api/v3/repositories/rpm/rpm/0002/versions/2/": { - "pulp_created": "2026-06-14T23:40:00Z", - "content_summary": {"present": {"rpm.package": {"count": 8934}}}, - }, - "/pulp/api/v3/repositories/rpm/rpm/0003/versions/5/": { - "pulp_created": "2026-06-16T08:05:00Z", - "content_summary": {"present": {"deb.package": {"count": 12500}}}, - }, - "/pulp/api/v3/repositories/rpm/rpm/0004/versions/1/": { - "pulp_created": "2026-05-30T11:20:00Z", - "content_summary": {"present": {"rpm.package": {"count": 87}}}, - }, -} +# 검증 상태를 섞는다: pass / warn(부분) / unset +_CFGS = [ + {"gpgcheck": 1, "repo-gpgcheck": 1}, + {"gpgcheck": 1, "repo-gpgcheck": 0}, + {}, +] + + +def _uuid(i: int) -> str: + return f"{i:04d}" + + +def _latest(i: int) -> int: + return (i % 5) + 1 # 1~5 + + +_REPOS: list[dict[str, Any]] = [ + { + "pulp_href": f"/pulp/api/v3/repositories/rpm/rpm/{_uuid(i)}/", + "name": name, + "latest_version_href": ( + f"/pulp/api/v3/repositories/rpm/rpm/{_uuid(i)}/versions/{_latest(i)}/" + ), + "repo_config": _CFGS[i % 3], + } + for i, name in enumerate(_NAMES, start=1) +] + + +def _synth_version(version_href: str) -> dict[str, Any]: + parts = version_href.rstrip("/").split("/") + n = int(parts[-1]) if parts[-1].isdigit() else 1 + base = 800 + (int(parts[-3]) if parts[-3].isdigit() else 1) * 53 + present = base + n * 137 + return { + "pulp_created": f"2026-06-{(n * 5) % 27 + 1:02d}T0{n}:11:00Z", + "content_summary": { + "present": {"rpm.package": {"count": present}}, + "added": {"rpm.package": {"count": n * 137}}, + "removed": {"rpm.package": {"count": n * 7}}, + }, + } class DemoPulpClient: - def close(self) -> None: # 인터페이스 호환 + def close(self) -> None: pass def status(self) -> dict[str, Any]: @@ -66,7 +92,7 @@ class DemoPulpClient: return _REPOS def get_version(self, version_href: str) -> dict[str, Any]: - return _VERSIONS.get(version_href, {}) + return _synth_version(version_href) def get_repo(self, uuid: str) -> dict[str, Any]: for r in _REPOS: @@ -74,59 +100,32 @@ class DemoPulpClient: return {**r, "remote": "/pulp/api/v3/remotes/rpm/rpm/demo/"} return {"remote": "/pulp/api/v3/remotes/rpm/rpm/demo/"} - def sync_repo(self, uuid: str, remote_href: str) -> str: - return f"/pulp/api/v3/tasks/demo-{uuid}/" - - def get_task(self, task_href: str) -> dict[str, Any]: - # 데모: 첫 폴링에서 바로 완료 처리 - return { - "state": "completed", - "progress_reports": [{"done": 120, "total": 120}], - "created_resources": ["/pulp/api/v3/repositories/rpm/rpm/demo/versions/9/"], - } - def list_versions(self, uuid: str) -> list[dict[str, Any]]: base = f"/pulp/api/v3/repositories/rpm/rpm/{uuid}/versions/" - return [ - { - "pulp_href": base + "1/", - "pulp_created": "2026-05-02T01:00:00Z", - "content_summary": { - "present": {"rpm.package": {"count": 4500}}, - "added": {"rpm.package": {"count": 4500}}, - "removed": {}, - }, - }, - { - "pulp_href": base + "2/", - "pulp_created": "2026-05-28T04:30:00Z", - "content_summary": { - "present": {"rpm.package": {"count": 4700}}, - "added": {"rpm.package": {"count": 250}}, - "removed": {"rpm.package": {"count": 50}}, - }, - }, - { - "pulp_href": base + "3/", - "pulp_created": "2026-06-15T02:11:00Z", - "content_summary": { - "present": {"rpm.package": {"count": 4821}}, - "added": {"rpm.package": {"count": 130}}, - "removed": {"rpm.package": {"count": 9}}, - }, - }, - ] + latest = 1 + for i, r in enumerate(_NAMES, start=1): + if _uuid(i) == uuid: + latest = _latest(i) + break + out = [] + for n in range(1, latest + 1): + href = f"{base}{n}/" + out.append({"pulp_href": href, **_synth_version(href)}) + return out def list_distributions(self) -> list[dict[str, Any]]: - # 저장소별 현재 배포 버전 (publication href 에 uuid-버전 인코딩) - deployed = {"0001": 2, "0002": 1, "0003": 3, "0004": 1} - return [ - { - "pulp_href": f"/pulp/api/v3/distributions/rpm/rpm/{u}/", - "publication": f"/pulp/api/v3/publications/rpm/rpm/{u}-{v}/", - } - for u, v in deployed.items() - ] + # 저장소별 현재 배포 버전 (보통 최신-1). publication href 에 uuid-버전 인코딩 + dists = [] + for i in range(1, len(_NAMES) + 1): + deployed = max(1, _latest(i) - 1) + u = _uuid(i) + dists.append( + { + "pulp_href": f"/pulp/api/v3/distributions/rpm/rpm/{u}/", + "publication": f"/pulp/api/v3/publications/rpm/rpm/{u}-{deployed}/", + } + ) + return dists def get_publication(self, publication_href: str) -> dict[str, Any]: tail = publication_href.rstrip("/").split("/")[-1] # "0001-2" @@ -137,6 +136,16 @@ class DemoPulpClient: ) } + def sync_repo(self, uuid: str, remote_href: str) -> str: + return f"/pulp/api/v3/tasks/demo-{uuid}/" + + def get_task(self, task_href: str) -> dict[str, Any]: + return { + "state": "completed", + "progress_reports": [{"done": 120, "total": 120}], + "created_resources": ["/pulp/api/v3/repositories/rpm/rpm/demo/versions/9/"], + } + def create_publication(self, version_href: str) -> str: return "/pulp/api/v3/tasks/demo-pub/" @@ -144,7 +153,6 @@ class DemoPulpClient: return "/pulp/api/v3/tasks/demo-dist/" def wait_for_task(self, task_href: str, attempts: int = 120, delay: float = 1.0): - # 데모: 즉시 완료. publication task 면 새 publication 리소스를 돌려준다. created = ( ["/pulp/api/v3/publications/rpm/rpm/demo-new/"] if "pub" in task_href diff --git a/app/pulp_client.py b/app/pulp_client.py index 29a5bd6..cfc4356 100644 --- a/app/pulp_client.py +++ b/app/pulp_client.py @@ -62,9 +62,14 @@ class PulpClient: return self.get(f"{API_PREFIX}/status/") def list_repos(self) -> list[dict[str, Any]]: - """RPM 저장소 목록: GET /pulp/api/v3/repositories/rpm/rpm/ → results.""" - data = self.get(f"{API_PREFIX}/repositories/rpm/rpm/") - return data.get("results", []) + """RPM 저장소 전체 목록. Pulp 페이지네이션(next)을 끝까지 따라가 모두 모은다.""" + results: list[dict[str, Any]] = [] + path: str | None = f"{API_PREFIX}/repositories/rpm/rpm/?limit=100" + while path: + data = self.get(path) + results.extend(data.get("results", [])) + path = data.get("next") # 절대 URL/상대경로 모두 httpx가 처리 + return results def get_version(self, version_href: str) -> dict[str, Any]: """RepositoryVersion 단건 조회 (href 기준). content_summary 등 포함.""" diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 53e9e76..c983993 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -1,11 +1,16 @@ -"""대시보드 (화면 1, 읽기 전용). +"""대시보드 (화면 1, 읽기 전용) + 목록 컨트롤(검색·정렬·페이지네이션). -GET / → 전체 페이지(상태 + 요약 카드 + 저장소 목록) -GET /repos → 저장소 목록 조각 (새로고침/폴링용 partial) +GET / 전체 페이지 +GET /repos 저장소 목록 전체 조각 (요약+검색창+표) — 새로고침용 +GET /repos/table 표+페이저 조각 — 검색/정렬/페이지 이동 대상 + +저장소 수십 개 규모 가정: 전체를 가져와 enrich 후 메모리에서 검색·정렬·페이지네이션한다. +(요약 카드는 검색/필터와 무관하게 항상 전체 인벤토리 기준.) """ from __future__ import annotations +from math import ceil from typing import Any import httpx @@ -18,17 +23,33 @@ from ..pulp_client import PulpClient router = APIRouter() +PAGE_SIZE = 20 -def _load_repos(pulp: PulpClient) -> dict[str, Any]: - """저장소 목록을 읽어 표시 모델 + 요약 + (실패 시) 에러 메시지를 만든다.""" + +def _load_repos( + pulp: PulpClient, + q: str = "", + sort: str = "name", + direction: str = "asc", + page: int = 1, +) -> dict[str, Any]: syncing = task_store.active_count() + base = {"q": q, "sort": sort, "dir": direction} try: - repos = pulp.list_repos() + raw = pulp.list_repos() except httpx.HTTPError as exc: - return {"repos": [], "summary": views.summarize([], syncing), "error": str(exc)} + return { + **base, + "repos": [], + "summary": views.summarize([], syncing), + "page": 1, + "pages": 1, + "total": 0, + "error": str(exc), + } repo_views: list[dict[str, Any]] = [] - for repo in repos: + for repo in raw: version = None vhref = repo.get("latest_version_href") if vhref: @@ -38,24 +59,65 @@ def _load_repos(pulp: PulpClient) -> dict[str, Any]: version = None # 버전 상세 실패는 치명적이지 않음 — 행은 표시 repo_views.append(views.build_repo_view(repo, version)) + summary = views.summarize(repo_views, syncing) # 전체 기준 + + ql = q.strip().lower() + filtered = [r for r in repo_views if ql in r["name"].lower()] if ql else repo_views + filtered = views.sort_repos(filtered, sort, direction) + + total = len(filtered) + pages = max(1, ceil(total / PAGE_SIZE)) + page = min(max(page, 1), pages) + window = filtered[(page - 1) * PAGE_SIZE : page * PAGE_SIZE] + return { - "repos": repo_views, - "summary": views.summarize(repo_views, syncing), + **base, + "repos": window, + "summary": summary, + "page": page, + "pages": pages, + "total": total, "error": None, } @router.get("/", response_class=HTMLResponse) def dashboard( - request: Request, pulp: PulpClient = Depends(get_pulp_client) + request: Request, + q: str = "", + sort: str = "name", + dir: str = "asc", + page: int = 1, + pulp: PulpClient = Depends(get_pulp_client), ) -> HTMLResponse: - return templates.TemplateResponse(request, "dashboard.html", _load_repos(pulp)) + return templates.TemplateResponse( + request, "dashboard.html", _load_repos(pulp, q, sort, dir, page) + ) @router.get("/repos", response_class=HTMLResponse) def repos_fragment( - request: Request, pulp: PulpClient = Depends(get_pulp_client) + request: Request, + q: str = "", + sort: str = "name", + dir: str = "asc", + page: int = 1, + pulp: PulpClient = Depends(get_pulp_client), ) -> HTMLResponse: return templates.TemplateResponse( - request, "partials/repo_list.html", _load_repos(pulp) + request, "partials/repo_list.html", _load_repos(pulp, q, sort, dir, page) + ) + + +@router.get("/repos/table", response_class=HTMLResponse) +def repos_table( + request: Request, + q: str = "", + sort: str = "name", + dir: str = "asc", + page: int = 1, + pulp: PulpClient = Depends(get_pulp_client), +) -> HTMLResponse: + return templates.TemplateResponse( + request, "partials/table_wrap.html", _load_repos(pulp, q, sort, dir, page) ) diff --git a/app/static/app.css b/app/static/app.css index a6180ef..6094d8e 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -153,10 +153,10 @@ button:disabled, [role="button"]:disabled { opacity: 0.45; } -/* 표 안 액션 버튼: 컴팩트 */ +/* 표 안 액션 버튼: 컴팩트하되 글자는 또렷하게 */ .btn-sm { padding: 7px 14px; - font-size: 13px; + font-size: 14px; margin: 0; width: auto; } @@ -213,7 +213,7 @@ button:disabled, margin: 0; } .table-card thead th { - font-size: 12px; + font-size: 13px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; @@ -224,6 +224,7 @@ button:disabled, } .table-card tbody td { height: 56px; + font-size: 14px; border-bottom: 1px solid var(--border); padding-block: 12px; vertical-align: middle; @@ -266,9 +267,9 @@ th.num { display: inline-flex; align-items: center; gap: 6px; - padding: 4px 10px; + padding: 4px 11px; border-radius: 999px; - font-size: 13px; + font-size: 14px; font-weight: 500; white-space: nowrap; } @@ -301,7 +302,8 @@ th.num { display: flex; align-items: center; gap: 12px; - margin-bottom: 16px; + margin-top: 20px; + margin-bottom: 20px; } .verify-detail small { color: var(--text-muted); @@ -362,6 +364,47 @@ th.num { color: var(--text-muted); } +/* 검색 툴바 / 정렬 헤더 / 페이저 */ +.list-toolbar { + margin-bottom: 14px; +} +.repo-search { + max-width: 280px; + margin: 0; + font-size: 14px; +} +.table-card thead th a.th-sort { + display: inline-flex; + align-items: center; + gap: 4px; + color: inherit; + text-decoration: none; + cursor: pointer; +} +.table-card thead th a.th-sort:hover { + color: var(--text-secondary); +} +.table-card thead th a.th-sort.is-active { + color: var(--accent); +} +.sort-ind { + font-size: 11px; +} +.pager { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + padding: 12px 16px; + border-top: 1px solid var(--border); + color: var(--text-muted); + font-size: 13px; +} +.empty-note { + padding: 16px; + color: var(--text-muted); +} + @media (max-width: 640px) { .summary { grid-template-columns: repeat(2, 1fr); diff --git a/app/templates/partials/repo_list.html b/app/templates/partials/repo_list.html index d7b0bf6..d0342a0 100644 --- a/app/templates/partials/repo_list.html +++ b/app/templates/partials/repo_list.html @@ -9,43 +9,15 @@
{{ summary.total_packages }}
총 패키지
- {% if repos %} -
- - - - - - - - - - - - - {% for repo in repos %} - - - - - - - - - {% endfor %} - -
저장소검증최신 버전패키지 수마지막 동기화
{{ repo.name }}{{ repo.gpg.label }}{% if repo.latest_version is not none %}v{{ repo.latest_version }}{% else %}—{% endif %}{% if repo.package_count is not none %}{{ repo.package_count }}{% else %}—{% endif %}{{ repo.last_sync or "—" }} -
- -
-
+
+ +
- {% else %} -

등록된 저장소가 없습니다.

- {% endif %} + + {% include "partials/table_wrap.html" %} {% endif %}
diff --git a/app/templates/partials/table_wrap.html b/app/templates/partials/table_wrap.html new file mode 100644 index 0000000..1197cb1 --- /dev/null +++ b/app/templates/partials/table_wrap.html @@ -0,0 +1,75 @@ +{# + 표 + 페이저 조각. 검색/정렬/페이지 이동의 swap 대상(#repo-table-wrap). + 정렬 헤더와 페이저는 현재 검색어(q)를 #repo-search 에서 hx-include 로 가져온다. + hidden state(sort/dir, data-state)는 검색창이 정렬 상태를 함께 보내도록 노출. +#} +
+ + + + {% macro sorth(col, label, extra="") %} + + + {{ label }}{% if sort == col %} {{ '↑' if dir == 'asc' else '↓' }}{% endif %} + + + {% endmacro %} + + {% if total %} +
+ + + + {{ sorth("name", "저장소") }} + {{ sorth("verified", "검증") }} + {{ sorth("version", "최신 버전", "num") }} + {{ sorth("packages", "패키지 수", "num") }} + {{ sorth("last_sync", "마지막 동기화") }} + + + + + {% for repo in repos %} + + + + + + + + + {% endfor %} + +
{{ repo.name }}{{ repo.gpg.label }}{% if repo.latest_version is not none %}v{{ repo.latest_version }}{% else %}—{% endif %}{% if repo.package_count is not none %}{{ repo.package_count }}{% else %}—{% endif %}{{ repo.last_sync or "—" }} +
+ +
+
+ + {% if pages > 1 %} +
+ + {{ page }} / {{ pages }} + +
+ {% endif %} +
+ {% elif q %} +

'{{ q }}' 검색 결과가 없습니다.

+ {% else %} +

등록된 저장소가 없습니다.

+ {% endif %} +
diff --git a/app/views.py b/app/views.py index e6ed643..1c9cc6f 100644 --- a/app/views.py +++ b/app/views.py @@ -139,6 +139,25 @@ def verification_detail(repo: dict[str, Any]) -> dict[str, Any]: } +_REPO_SORT_KEYS = { + "name": lambda r: (r["name"] or "").lower(), + "verified": lambda r: {"pass": 0, "warn": 1, "unset": 2}.get( + (r["gpg"] or {}).get("level"), 3 + ), + "version": lambda r: r["latest_version"] if r["latest_version"] is not None else -1, + "packages": lambda r: r["package_count"] if r["package_count"] is not None else -1, + "last_sync": lambda r: r["last_sync"] or "", +} + + +def sort_repos( + repo_views: list[dict[str, Any]], key: str, direction: str = "asc" +) -> list[dict[str, Any]]: + """대시보드 헤더 정렬. 알 수 없는 key 는 이름순으로 폴백.""" + fn = _REPO_SORT_KEYS.get(key) or _REPO_SORT_KEYS["name"] + return sorted(repo_views, key=fn, reverse=(direction == "desc")) + + def summarize(repo_views: list[dict[str, Any]], syncing: int = 0) -> dict[str, int]: """상단 요약 카드 수치. syncing 은 task_store.active_count() 에서 주입.""" return { diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index be339f4..31ff001 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -71,3 +71,72 @@ def test_dashboard_shows_error_when_pulp_down(): resp = client.get("/") assert resp.status_code == 200 # 페이지는 뜨고 에러 메시지를 보여줌 assert "불러오지 못했습니다" in resp.text + + +# --- 검색 / 정렬 / 페이지네이션 ------------------------------------------------- + +PASS = {"gpgcheck": 1, "repo-gpgcheck": 1} + + +class _ReposPulp: + """specs: (name, package_count) 목록. 검증은 모두 pass, 버전 1.""" + + def __init__(self, specs): + self._repos = [] + self._counts = {} + for i, (name, count) in enumerate(specs, start=1): + href = f"/pulp/api/v3/repositories/rpm/rpm/{i:04d}/" + vhref = f"{href}versions/1/" + self._repos.append( + { + "pulp_href": href, + "name": name, + "latest_version_href": vhref, + "repo_config": PASS, + } + ) + self._counts[vhref] = count + + def list_repos(self): + return self._repos + + def get_version(self, href): + return { + "content_summary": { + "present": {"rpm.package": {"count": self._counts.get(href, 0)}} + } + } + + +def test_repos_table_search_filters_by_name(): + _use( + _ReposPulp( + [("rocky9-baseos", 10), ("ubuntu2204-main", 20), ("rocky8-baseos", 30)] + ) + ) + body = client.get("/repos/table", params={"q": "rocky"}).text + assert "rocky9-baseos" in body + assert "rocky8-baseos" in body + assert "ubuntu2204-main" not in body + + +def test_repos_table_sort_by_packages_desc(): + _use(_ReposPulp([("alpha", 10), ("bravo", 300), ("charlie", 100)])) + body = client.get("/repos/table", params={"sort": "packages", "dir": "desc"}).text + # 패키지 많은 순: bravo(300) → charlie(100) → alpha(10) + assert body.index("bravo") < body.index("charlie") < body.index("alpha") + + +def test_repos_table_paginates(): + specs = [(f"repo-{i:02d}", i) for i in range(1, 26)] # 25개 + _use(_ReposPulp(specs)) + page1 = client.get( + "/repos/table", params={"sort": "name", "dir": "asc", "page": 1} + ).text + page2 = client.get( + "/repos/table", params={"sort": "name", "dir": "asc", "page": 2} + ).text + assert "repo-01" in page1 and "repo-20" in page1 + assert "repo-21" not in page1 + assert "repo-21" in page2 and "repo-25" in page2 + assert "1 / 2" in page1 diff --git a/tests/test_pulp_client.py b/tests/test_pulp_client.py index 05596de..634b7a4 100644 --- a/tests/test_pulp_client.py +++ b/tests/test_pulp_client.py @@ -63,6 +63,25 @@ def test_get_task_returns_state(): assert task["state"] == "running" +@respx.mock +def test_list_repos_follows_pagination(): + first = f"{PULP_TEST_URL}/pulp/api/v3/repositories/rpm/rpm/?limit=100" + second = f"{PULP_TEST_URL}/pulp/api/v3/repositories/rpm/rpm/?limit=100&offset=100" + respx.get(first).mock( + return_value=httpx.Response( + 200, json={"results": [{"name": "a"}], "next": second} + ) + ) + respx.get(second).mock( + return_value=httpx.Response( + 200, json={"results": [{"name": "b"}], "next": None} + ) + ) + with PulpClient() as pulp: + repos = pulp.list_repos() + assert [r["name"] for r in repos] == ["a", "b"] + + @respx.mock def test_create_publication_posts_version_and_returns_task(): import json diff --git a/tests/test_views.py b/tests/test_views.py index a41dfca..6e3df50 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -161,6 +161,39 @@ def test_deploy_preview_net_change(): assert p["net_sign"] == "+" +def test_sort_repos_by_name_packages_verified(): + rv = [ + { + "name": "bravo", + "gpg": {"level": "unset"}, + "latest_version": 1, + "package_count": 50, + "last_sync": "", + }, + { + "name": "alpha", + "gpg": {"level": "pass"}, + "latest_version": 3, + "package_count": 200, + "last_sync": "", + }, + ] + assert [r["name"] for r in views.sort_repos(rv, "name", "asc")] == [ + "alpha", + "bravo", + ] + assert [r["name"] for r in views.sort_repos(rv, "name", "desc")] == [ + "bravo", + "alpha", + ] + assert [r["package_count"] for r in views.sort_repos(rv, "packages", "desc")] == [ + 200, + 50, + ] + # verified: pass 가 unset 보다 앞 + assert views.sort_repos(rv, "verified", "asc")[0]["gpg"]["level"] == "pass" + + def test_deploy_preview_handles_no_current_deploy(): vv = [{"number": 1, "package_count": 50}] p = views.deploy_preview(vv, target_number=1, deployed_number=None)