From c343b683178ab243a5f6db07a226b1129ed7c1dc Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Fri, 16 Oct 2026 21:37:51 +0900 Subject: [PATCH] feat: add the systeminfo, stats, log, backup and suggest commands Wrap the admin APIs that report on and maintain a running Fess: - systeminfo / stats: show the server information and statistics (text tables, full data with json/yaml). - log list|download, backup list|download: list the files and stream one to --dest; an existing destination is kept unless --force is given, and a failed transfer leaves no partial file. log download checks the id against the list first because Fess answers an unknown id with an empty download. backup download requests the id with a trailing slash because Fess does not run the action for a path whose last segment has an extension. - suggest info|delete-all|delete-document|delete-query: show the word counts and delete words; the deletes ask for confirmation unless --yes is given. All five APIs exist in 14.19.2, 15.6.1, 15.7.0, 15.8.0 and 15.9.0. Adds unit tests (including a download test against a local HTTP server), integration tests and the skills reference pages. --- skills/fessctl/SKILL.md | 7 +- skills/fessctl/references/features/backup.md | 48 +++++++ skills/fessctl/references/features/log.md | 45 ++++++ skills/fessctl/references/features/stats.md | 46 ++++++ skills/fessctl/references/features/suggest.md | 45 ++++++ .../fessctl/references/features/systeminfo.md | 44 ++++++ skills/fessctl/references/workflows.md | 2 +- src/fessctl/api/client.py | 120 ++++++++++++++++ src/fessctl/cli.py | 20 +-- src/fessctl/commands/backup.py | 92 ++++++++++++ src/fessctl/commands/log.py | 101 +++++++++++++ src/fessctl/commands/stats.py | 67 +++++++++ src/fessctl/commands/suggest.py | 134 ++++++++++++++++++ src/fessctl/commands/systeminfo.py | 58 ++++++++ tests/commands/test_backup.py | 61 ++++++++ tests/commands/test_log.py | 67 +++++++++ tests/commands/test_stats.py | 33 +++++ tests/commands/test_suggest.py | 58 ++++++++ tests/commands/test_systeminfo.py | 34 +++++ tests/unit/test_backup.py | 87 ++++++++++++ tests/unit/test_client_downloads.py | 118 +++++++++++++++ tests/unit/test_client_ops.py | 49 +++++++ tests/unit/test_log.py | 128 +++++++++++++++++ tests/unit/test_stats.py | 70 +++++++++ tests/unit/test_suggest.py | 90 ++++++++++++ tests/unit/test_systeminfo.py | 75 ++++++++++ 26 files changed, 1687 insertions(+), 12 deletions(-) create mode 100644 skills/fessctl/references/features/backup.md create mode 100644 skills/fessctl/references/features/log.md create mode 100644 skills/fessctl/references/features/stats.md create mode 100644 skills/fessctl/references/features/suggest.md create mode 100644 skills/fessctl/references/features/systeminfo.md create mode 100644 src/fessctl/commands/backup.py create mode 100644 src/fessctl/commands/log.py create mode 100644 src/fessctl/commands/stats.py create mode 100644 src/fessctl/commands/suggest.py create mode 100644 src/fessctl/commands/systeminfo.py create mode 100644 tests/commands/test_backup.py create mode 100644 tests/commands/test_log.py create mode 100644 tests/commands/test_stats.py create mode 100644 tests/commands/test_suggest.py create mode 100644 tests/commands/test_systeminfo.py create mode 100644 tests/unit/test_backup.py create mode 100644 tests/unit/test_client_downloads.py create mode 100644 tests/unit/test_client_ops.py create mode 100644 tests/unit/test_log.py create mode 100644 tests/unit/test_stats.py create mode 100644 tests/unit/test_suggest.py create mode 100644 tests/unit/test_systeminfo.py diff --git a/skills/fessctl/SKILL.md b/skills/fessctl/SKILL.md index a24fc0b..14d5398 100644 --- a/skills/fessctl/SKILL.md +++ b/skills/fessctl/SKILL.md @@ -1,6 +1,6 @@ --- name: fessctl -description: Operate Fess via fessctl CLI (admin API client). Use when managing webconfigs, file/data configs, schedulers, users/roles/groups, dictionaries, access tokens, or any Fess admin resource from Claude. Covers 25 resource types and cross-feature workflows like initial crawl setup. +description: Operate Fess via fessctl CLI (admin API client). Use when managing webconfigs, file/data configs, schedulers, users/roles/groups, dictionaries, access tokens, or any Fess admin resource from Claude. Covers 30 resource types and cross-feature workflows like initial crawl setup. license: Apache-2.0 version: 0.1.0 --- @@ -70,3 +70,8 @@ Each file documents one Fess admin feature: what it is, when to use it, fessctl | Path mapping | references/features/pathmap.md | | Duplicate host | references/features/duplicatehost.md | | Request header | references/features/reqheader.md | +| System info | references/features/systeminfo.md | +| Server stats | references/features/stats.md | +| Log files | references/features/log.md | +| Backup files | references/features/backup.md | +| Suggest words | references/features/suggest.md | diff --git a/skills/fessctl/references/features/backup.md b/skills/fessctl/references/features/backup.md new file mode 100644 index 0000000..ab7a828 --- /dev/null +++ b/skills/fessctl/references/features/backup.md @@ -0,0 +1,48 @@ +# Backup files (fessctl `backup`) + +## What it is +The files the Admin UI backup page offers for download: index dumps in Elasticsearch/OpenSearch bulk format (`fess_config.bulk`, `fess_basic_config.bulk`, `fess_user.bulk`), the `system.properties` file, the index mappings (`fess.json`, `doc.json`) and the search, click, favorite, user-info and chat logs as NDJSON. The admin API lists the ids and streams one file. It is download only; restoring a backup is not available through the admin API. + +Available in every version fessctl supports (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0), with differences noted below. + +## When to use +- Take a copy of the Fess configuration (crawl configs, labels, users, system properties) before an upgrade or a risky change. +- Export the search log for analysis outside Fess. + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `list` | List the downloadable files. | none | +| `download` | Write one file to a local file. Refuses to overwrite unless `--force` is given. | ` --dest PATH` | + +Always reconfirm with `fessctl backup --help`. + +## Resource JSON shape +```json +{ + "response": { + "status": 0, + "total": 2, + "files": [ + {"name": "fess_config.bulk", "id": "fess_config.bulk"}, + {"name": "search_log.ndjson", "id": "search_log.ndjson"} + ] + } +} +``` +The id equals the name. `download` with `--output json` returns `{"response": {"status": 0, "path": "...", "size": 6463}}`. An unknown id returns `status` 1 with `Could not find any backup index.` (`Could not find index for backup.` in 14.x) and creates no file. + +## Gotchas +- The dumps contain everything stored in the index, including access tokens and crawl credentials. Treat the files as secrets. +- An empty file is a valid result: a search log with no entries downloads as 0 bytes. +- Before 15.9.0 `fess.json` and `doc.json` are listed but download as empty files (the mapping export was added in 15.9.0), and `chat_log.ndjson` is listed only from 15.9.0. +- Fess answers a path whose last segment has an extension (`.../backup/file/fess_config.bulk`) with a bare `404 Not Found.` in 15.9 and a redirect in older versions, instead of running the action. fessctl requests the id with a trailing slash, which Fess serves. +- The file is written to a temporary `.part` file next to the destination and renamed on success, so a failed transfer leaves no partial file. Large dumps are streamed; the read timeout applies per chunk (60 s). + +## Examples +```bash +fessctl backup list +fessctl backup download fess_config.bulk --dest ./fess_config.bulk +fessctl backup download system.properties --dest ./system.properties --force +fessctl backup download search_log.ndjson --dest ./search_log.ndjson -o json +``` diff --git a/skills/fessctl/references/features/log.md b/skills/fessctl/references/features/log.md new file mode 100644 index 0000000..dcd7e48 --- /dev/null +++ b/skills/fessctl/references/features/log.md @@ -0,0 +1,45 @@ +# Log files (fessctl `log`) + +## What it is +The log files in the log directory of the Fess server (`audit.log`, `fess.log`, `fess-crawler.log`, ... plus rotated `*.log.gz`), as listed on the Admin UI log files page. The admin API lists them and streams one file. + +Available in every version fessctl supports (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). Only names ending in `.log` or `.log.gz` are served. + +## When to use +- Fetch the server logs for a bug report without shell access to the server. +- Check the crawler, thumbnail or audit log after a failed job. + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `list` | List the log files with their id and last-modified time. | none | +| `download` | Write one log file to a local file. Refuses to overwrite unless `--force` is given. | ` --dest PATH` | + +Always reconfirm with `fessctl log --help`. + +## Resource JSON shape +```json +{ + "response": { + "status": 0, + "total": 2, + "files": [ + {"name": "fess.log", "id": "ZmVzcy5sb2c=", "lastModified": "2026-10-16T12:23:38"}, + {"name": "audit.log", "id": "YXVkaXQubG9n", "lastModified": "2026-10-16T12:23:49"} + ] + } +} +``` +The `id` is the URL-safe Base64 of the file name. `lastModified` is in the server's local time without a zone (and camelCase, unlike the rest of the API). `download` with `--output json` returns `{"response": {"status": 0, "path": "...", "size": 981}}`. + +## Gotchas +- An empty log file downloads as an empty file; a log that Fess has not written to yet is common. Fess also answers an id that names no log file with an empty download, so `download` checks the id against `list` first and fails with `not found` instead. +- Fess decodes the id as standard Base64 while `list` hands out URL-safe Base64. The two differ only for `-` and `_` in an id, which appear only for file names containing characters such as `>`, `?`, `~` or non-ASCII; such a log file cannot be downloaded through the API (`Could not find ...`). +- The file is written to a temporary `.part` file next to the destination and renamed on success, so a failed transfer leaves no partial file. + +## Examples +```bash +fessctl log list +fessctl log download "$(fessctl log list -o json | jq -r '.response.files[] | select(.name == "fess.log") | .id')" --dest ./fess.log +fessctl log download YXVkaXQubG9n --dest ./audit.log --force +``` diff --git a/skills/fessctl/references/features/stats.md b/skills/fessctl/references/features/stats.md new file mode 100644 index 0000000..a09125b --- /dev/null +++ b/skills/fessctl/references/features/stats.md @@ -0,0 +1,46 @@ +# Stats (fessctl `stats`) + +## What it is +Runtime statistics of the Fess server: JVM memory, garbage collection and threads, OS memory and load, process file descriptors and CPU, the search engine cluster health, and the filesystem roots. + +Read only. Available in every version fessctl supports (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). + +## When to use +- Check heap usage, thread counts or the load average of the Fess process. +- Check the search engine cluster status (`engine.status`) and shard counts, or the free disk space (`fs`). + +## Subcommand surface +`stats` takes no subcommand; it takes `--output text|json|yaml`. + +| Command | Purpose | +| --- | --- | +| `fessctl stats` | Show the statistics. Text output is a `NAME \| VALUE` table of dotted names such as `jvm.memory.heap.used`; list items are named by their key or path (`jvm.gc[young].count`, `fs[/].free`). `json`/`yaml` return the response unchanged. | + +Always reconfirm with `fessctl stats --help`. + +## Resource JSON shape +```json +{ + "response": { + "status": 0, + "stats": { + "jvm": {"memory": {"heap": {"used": 366768360, "max": 536870912, "percent": 68}}, "gc": [{"key": "young", "count": 10, "time": 48}], "threads": {"count": 37, "peak": 37}, "uptime": 26797}, + "os": {"memory": {"physical": {"free": 5921939456, "total": 50425147392}}, "cpu": {"percent": 0}, "load_averages": [2.12, 0.98, 0.86]}, + "process": {"file_fescriptor": {"open": 221, "max": 1048576}}, + "engine": {"status": "green", "cluster_name": "fess-search", "number_of_nodes": 1, "active_shards": 65, "unassigned_shards": 0}, + "fs": [{"path": "/", "used": 182361546752, "free": 101632278528, "total": 270233210880, "percent": 67}] + } + } +} +``` +Sizes are bytes, times are milliseconds. A few keys are spelled oddly by the server and passed through unchanged (for example `process.file_fescriptor`). + +## Gotchas +- When Fess cannot reach the search engine, the call still succeeds: `engine.status` is `red` and `engine.exception` carries the message. Check the field, not the exit code. + +## Examples +```bash +fessctl stats +fessctl stats -o json | jq '.response.stats.engine.status' +fessctl stats -o json | jq '.response.stats.jvm.memory.heap.percent' +``` diff --git a/skills/fessctl/references/features/suggest.md b/skills/fessctl/references/features/suggest.md new file mode 100644 index 0000000..4b45cb5 --- /dev/null +++ b/skills/fessctl/references/features/suggest.md @@ -0,0 +1,45 @@ +# Suggest words (fessctl `suggest`) + +## What it is +The suggest index holds the words behind the search box auto-complete. Fess fills it from two sources: the content of crawled documents (document words) and the queries users searched for (query words). The Admin UI Suggest page shows the counts and offers the deletes; the admin API offers the same. + +Available in every version fessctl supports (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). + +## When to use +- Check whether suggestions exist (`info`): the counts are 0 until the **Suggest Indexer** scheduler job has run. +- Remove words after a change in the crawl targets, or remove query words that should not be suggested. + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `info` | Show `total_words_num`, `document_words_num` and `query_words_num`. | none | +| `delete-all` | Delete every word. Fess re-adds the elevate words afterwards. Asks for confirmation unless `--yes` is given. | none | +| `delete-document` | Delete the words that come from documents. Asks for confirmation unless `--yes` is given. | none | +| `delete-query` | Delete the words that come from search queries. Asks for confirmation unless `--yes` is given. | none | + +Always reconfirm with `fessctl suggest --help`. + +## Resource JSON shape +```json +{ + "response": { + "status": 0, + "setting": {"total_words_num": 17, "document_words_num": 17, "query_words_num": 4} + } +} +``` +The delete commands return only `status` (and `version`). + +## Gotchas +- A word can come from both sources, so `document_words_num` plus `query_words_num` can exceed `total_words_num`; `delete-document` removes the document origin of such a word and `delete-query` the query origin. +- Deleted words come back: the **Suggest Indexer** job (`fessctl scheduler start suggest_indexer`) re-creates the document words from the index and the query words from the search log. +- The counts are read from the search engine and can lag a moment behind an index run. +- None of these APIs needs the CodeLibs search engine plugins (the 15.9 `404` for plugin-less engines applies to the dictionary APIs only). + +## Examples +```bash +fessctl suggest info +fessctl suggest delete-query --yes +fessctl suggest delete-all --yes --output json +fessctl scheduler start suggest_indexer +``` diff --git a/skills/fessctl/references/features/systeminfo.md b/skills/fessctl/references/features/systeminfo.md new file mode 100644 index 0000000..49c2a3d --- /dev/null +++ b/skills/fessctl/references/features/systeminfo.md @@ -0,0 +1,44 @@ +# System info (fessctl `systeminfo`) + +## What it is +The system information of the running Fess server, the data of the Admin UI system information page: the properties to attach to a bug report, the environment variables, the `fess_config.properties` values in effect, and the JVM system properties. + +Read only. Available in every version fessctl supports (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). + +## When to use +- Attach the server environment to a bug report or support request. +- Check which configuration value Fess is really using (`fess_props`) or which environment variable reached the JVM (`env_props`). + +## Subcommand surface +`systeminfo` takes no subcommand; it takes `--output text|json|yaml`. + +| Command | Purpose | +| --- | --- | +| `fessctl systeminfo` | Show the four property groups. Text output is one `NAME \| VALUE` table per group (the Fess group alone has several hundred rows); `json`/`yaml` return the full response. | + +Always reconfirm with `fessctl systeminfo --help`. + +## Resource JSON shape +```json +{ + "response": { + "status": 0, + "version": "15.9", + "bug_report_props": [{"label": "java.runtime.version", "value": "21.0.12.1+1-LTS"}], + "env_props": [{"label": "FESS_PORT", "value": "8080"}], + "fess_props": [{"label": "domain.title", "value": "Fess"}], + "system_props": [{"label": "java.specification.version", "value": "21"}] + } +} +``` + +## Gotchas +- The output includes environment variables and configuration values of the server; review it before pasting it into a public issue. +- Use `--output json` with `jq` to pick one value: `fessctl systeminfo -o json | jq -r '.response.fess_props[] | select(.label == "domain.title") | .value'`. + +## Examples +```bash +fessctl systeminfo +fessctl systeminfo --output yaml +fessctl systeminfo -o json | jq -r '.response.bug_report_props[] | "\(.label)=\(.value)"' +``` diff --git a/skills/fessctl/references/workflows.md b/skills/fessctl/references/workflows.md index 750f348..69762f8 100644 --- a/skills/fessctl/references/workflows.md +++ b/skills/fessctl/references/workflows.md @@ -198,4 +198,4 @@ fessctl badword create --suggest-word internal-test ## Out of scope (today) -These recipes intentionally restrict themselves to features fessctl currently wraps. Operations that depend on `failureurl`, `plugin`, `backup`, `searchlist`, `stats`, `storage`, `suggest`, `systeminfo`, or `documents` are not represented here because fessctl 0.1.0 does not expose those subcommands. When fessctl adds them, update this file with the additional recipes. +These recipes intentionally restrict themselves to features fessctl currently wraps. Operations that depend on `failureurl`, `plugin`, `searchlist`, `storage`, or `documents` are not represented here because fessctl 0.1.0 does not expose those subcommands. When fessctl adds them, update this file with the additional recipes. diff --git a/src/fessctl/api/client.py b/src/fessctl/api/client.py index 0d837c3..a461af0 100644 --- a/src/fessctl/api/client.py +++ b/src/fessctl/api/client.py @@ -1,5 +1,6 @@ import json from enum import Enum +from pathlib import Path from typing import Dict, List, Optional import httpx @@ -129,6 +130,45 @@ def send_request( content=f"Invalid JSON response (HTTP {response.status_code}): {response.text}" ) from e + def _download(self, url: str, dest: Path) -> dict: + """ + Streams a file download to ``dest``. + + Fess answers a failed download with a JSON error body, which is returned like any other + response; a successful download returns ``{"response": {"status": 0, "path": ..., "size": ...}}``. + The file is written next to ``dest`` first, so a failed transfer leaves no partial file. + """ + part = dest.with_name(dest.name + ".part") + headers = {"Authorization": self.admin_api_headers["Authorization"]} + try: + with httpx.stream( + "GET", url, headers=headers, timeout=httpx.Timeout(self.timeout, read=60.0) + ) as response: + # A backup may itself be application/x-ndjson, so match the JSON error type exactly. + if response.status_code != 200 or response.headers.get("content-type", "").startswith("application/json"): + response.read() + try: + return response.json() + except json.decoder.JSONDecodeError as e: + raise FessAPIClientError( + status_code=response.status_code, + content=f"Invalid JSON response (HTTP {response.status_code}): {response.text}" + ) from e + size = 0 + with part.open("wb") as f: + for chunk in response.iter_bytes(): + f.write(chunk) + size += len(chunk) + part.replace(dest) + except httpx.HTTPError as e: + raise FessAPIClientError( + status_code=-1, + content=f"Network error: {str(e)}" + ) from e + finally: + part.unlink(missing_ok=True) + return {"response": {"status": 0, "path": str(dest), "size": size}} + def ping(self) -> dict: """ Sends a GET request to the health endpoint of the API to check the service status. @@ -712,6 +752,86 @@ def install_theme(self, name: str, version: str) -> dict: url = f"{self.base_url}/api/admin/theme" return self.send_request(Action.CREATE, url, json_data={"name": name, "version": version}) + # SystemInfo / Stats APIs + + def get_systeminfo(self) -> dict: + """ + Retrieves the system information (bug report, environment, Fess and system properties). + """ + url = f"{self.base_url}/api/admin/systeminfo" + return self.send_request(Action.GET, url) + + def get_stats(self) -> dict: + """ + Retrieves the server statistics (JVM, OS, process, engine and filesystem). + """ + url = f"{self.base_url}/api/admin/stats" + return self.send_request(Action.GET, url) + + # Log APIs + + def get_log_files(self) -> dict: + """ + Retrieves the log files in the log directory of the Fess server. + """ + url = f"{self.base_url}/api/admin/log/files" + return self.send_request(Action.GET, url) + + def download_log_file(self, log_id: str, dest: Path) -> dict: + """ + Downloads a log file to ``dest``. ``log_id`` is the ``id`` returned by get_log_files. + """ + url = f"{self.base_url}/api/admin/log/file/{log_id}" + return self._download(url, dest) + + # Backup APIs + + def get_backup_files(self) -> dict: + """ + Retrieves the files that can be downloaded as a backup. + """ + url = f"{self.base_url}/api/admin/backup/files" + return self.send_request(Action.GET, url) + + def download_backup_file(self, backup_id: str, dest: Path) -> dict: + """ + Downloads a backup file to ``dest``. ``backup_id`` is the ``id`` returned by get_backup_files. + """ + # An id such as "fess_config.bulk" ends in an extension, and Fess answers such a path + # with a bare "404 Not Found." instead of running the action; the trailing slash avoids it. + url = f"{self.base_url}/api/admin/backup/file/{backup_id}/" + return self._download(url, dest) + + # Suggest APIs + + def get_suggest_info(self) -> dict: + """ + Retrieves the number of words in the suggest index. + """ + url = f"{self.base_url}/api/admin/suggest" + return self.send_request(Action.GET, url) + + def delete_all_suggest_words(self) -> dict: + """ + Deletes every word from the suggest index. + """ + url = f"{self.base_url}/api/admin/suggest/all" + return self.send_request(Action.DELETE, url) + + def delete_document_suggest_words(self) -> dict: + """ + Deletes the words that come from documents from the suggest index. + """ + url = f"{self.base_url}/api/admin/suggest/document" + return self.send_request(Action.DELETE, url) + + def delete_query_suggest_words(self) -> dict: + """ + Deletes the words that come from search queries from the suggest index. + """ + url = f"{self.base_url}/api/admin/suggest/query" + return self.send_request(Action.DELETE, url) + # WebAuth APIs def create_webauth(self, config: dict) -> dict: diff --git a/src/fessctl/cli.py b/src/fessctl/cli.py index 9fbe1df..882bdfb 100644 --- a/src/fessctl/cli.py +++ b/src/fessctl/cli.py @@ -7,7 +7,7 @@ from fessctl.config.settings import Settings from fessctl.utils import format_result_markdown, output_error from fessctl.commands.accesstoken import accesstoken_app -# from fessctl.commands.backup import backup_app +from fessctl.commands.backup import backup_app from fessctl.commands.badword import badword_app from fessctl.commands.boostdoc import boostdoc_app from fessctl.commands.crawlinginfo import crawlinginfo_app @@ -23,7 +23,7 @@ from fessctl.commands.joblog import joblog_app from fessctl.commands.keymatch import keymatch_app from fessctl.commands.labeltype import labeltype_app -# from fessctl.commands.log import log_app +from fessctl.commands.log import log_app from fessctl.commands.pathmap import pathmap_app # from fessctl.commands.plugin import plugin_app from fessctl.commands.role import role_app @@ -32,12 +32,12 @@ from fessctl.commands.reqheader import reqheader_app # from fessctl.commands.searchlist import searchlist_app from fessctl.commands.scheduler import scheduler_app -# from fessctl.commands.stats import stats_app +from fessctl.commands.stats import stats_app # from fessctl.commands.storage import storage_app -# from fessctl.commands.suggest import suggest_app +from fessctl.commands.suggest import suggest_app from fessctl.commands.tagtype import tagtype_app from fessctl.commands.theme import theme_app -# from fessctl.commands.systeminfo import systeminfo_app +from fessctl.commands.systeminfo import systeminfo_app from fessctl.commands.user import user_app from fessctl.commands.webauth import webauth_app from fessctl.commands.webconfig import webconfig_app @@ -45,7 +45,7 @@ app = typer.Typer(no_args_is_help=True) app.add_typer(accesstoken_app, name="accesstoken") -# app.add_typer(backup_app, name="backup") +app.add_typer(backup_app, name="backup") app.add_typer(badword_app, name="badword") app.add_typer(boostdoc_app, name="boostdoc") app.add_typer(crawlinginfo_app, name="crawlinginfo") @@ -61,7 +61,7 @@ app.add_typer(joblog_app, name="joblog") app.add_typer(keymatch_app, name="keymatch") app.add_typer(labeltype_app, name="labeltype") -# app.add_typer(log_app, name="log") +app.add_typer(log_app, name="log") app.add_typer(pathmap_app, name="pathmap") # app.add_typer(plugin_app, name="plugin") app.add_typer(role_app, name="role") @@ -70,12 +70,12 @@ app.add_typer(reqheader_app, name="reqheader") # app.add_typer(searchlist_app, name="searchlist") app.add_typer(scheduler_app, name="scheduler") -# app.add_typer(stats_app, name="stats") +app.add_typer(stats_app, name="stats") # app.add_typer(storage_app, name="storage") -# app.add_typer(suggest_app, name="suggest") +app.add_typer(suggest_app, name="suggest") app.add_typer(tagtype_app, name="tagtype") app.add_typer(theme_app, name="theme") -# app.add_typer(systeminfo_app, name="systeminfo") +app.add_typer(systeminfo_app, name="systeminfo") app.add_typer(user_app, name="user") app.add_typer(webauth_app, name="webauth") app.add_typer(webconfig_app, name="webconfig") diff --git a/src/fessctl/commands/backup.py b/src/fessctl/commands/backup.py new file mode 100644 index 0000000..42e86de --- /dev/null +++ b/src/fessctl/commands/backup.py @@ -0,0 +1,92 @@ +import json +from pathlib import Path + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_list_markdown, format_result_markdown, output_error + +backup_app = typer.Typer() + + +@backup_app.command("list") +def list_backup_files( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + List the files that can be downloaded as a backup (index dumps, search logs, settings). + """ + client = FessAPIClient(Settings()) + try: + result = client.get_backup_files() + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + files = response.get("files", []) + if not files: + typer.echo("No Backup files found.") + else: + typer.echo(format_list_markdown("Backup files", files, [ + ("NAME", "name"), + ("ID", "id"), + ])) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to list Backup files. {message} Status code: {status}", "Backup", "list")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Backup", "list") + raise typer.Exit(code=1) + + +@backup_app.command("download") +def download_backup_file( + backup_id: str = typer.Argument(..., help="Backup file ID (the id shown by `backup list`)"), + dest: Path = typer.Option(..., "--dest", "-d", help="File to write the backup file to"), + force: bool = typer.Option( + False, "--force", "-f", help="Overwrite the destination file if it exists" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Download a backup file by ID. + """ + client = FessAPIClient(Settings()) + try: + if dest.exists() and not force: + raise FileExistsError(f"'{dest}' already exists. Use --force to overwrite it.") + + result = client.download_backup_file(backup_id, dest) + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + typer.echo(format_result_markdown(True, f"Backup file '{backup_id}' downloaded to '{dest}' ({response.get('size')} bytes).", "Backup", "download", backup_id)) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to download Backup file. {message} Status code: {status}", "Backup", "download")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Backup", "download") + raise typer.Exit(code=1) diff --git a/src/fessctl/commands/log.py b/src/fessctl/commands/log.py new file mode 100644 index 0000000..a98c642 --- /dev/null +++ b/src/fessctl/commands/log.py @@ -0,0 +1,101 @@ +import json +from pathlib import Path + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_list_markdown, format_result_markdown, output_error + +log_app = typer.Typer() + + +@log_app.command("list") +def list_log_files( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + List the log files in the log directory of the Fess server. + """ + client = FessAPIClient(Settings()) + try: + result = client.get_log_files() + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + files = response.get("files", []) + if not files: + typer.echo("No Log files found.") + else: + typer.echo(format_list_markdown("Log files", files, [ + ("NAME", "name"), + ("ID", "id"), + ("LAST MODIFIED", "lastModified"), + ])) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to list Log files. {message} Status code: {status}", "Log", "list")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Log", "list") + raise typer.Exit(code=1) + + +@log_app.command("download") +def download_log_file( + log_id: str = typer.Argument(..., help="Log file ID (the id shown by `log list`)"), + dest: Path = typer.Option(..., "--dest", "-d", help="File to write the log file to"), + force: bool = typer.Option( + False, "--force", "-f", help="Overwrite the destination file if it exists" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Download a log file by ID. + """ + client = FessAPIClient(Settings()) + try: + if dest.exists() and not force: + raise FileExistsError(f"'{dest}' already exists. Use --force to overwrite it.") + + # Fess answers an id that names no log file with an empty download, which cannot be + # told from an empty log file, so check the id against the list first. + result = client.get_log_files() + if result.get("response", {}).get("status", 1) == 0: + ids = [f.get("id") for f in result["response"].get("files", [])] + if log_id in ids: + result = client.download_log_file(log_id, dest) + else: + result = {"response": {"status": 1, "message": f"Log file '{log_id}' not found. See `log list` for the IDs."}} + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + typer.echo(format_result_markdown(True, f"Log file '{log_id}' downloaded to '{dest}' ({response.get('size')} bytes).", "Log", "download", log_id)) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to download Log file. {message} Status code: {status}", "Log", "download")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Log", "download") + raise typer.Exit(code=1) diff --git a/src/fessctl/commands/stats.py b/src/fessctl/commands/stats.py new file mode 100644 index 0000000..055c614 --- /dev/null +++ b/src/fessctl/commands/stats.py @@ -0,0 +1,67 @@ +import json + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_list_markdown, format_result_markdown, output_error + +stats_app = typer.Typer() + + +def flatten(value, prefix: str = "") -> list[dict]: + """ + Flatten nested stats into NAME/VALUE rows such as ``jvm.memory.heap.used``. + List items are named by their ``key`` or ``path`` (``jvm.gc[young].count``), else by position. + """ + if isinstance(value, dict): + return [row for k, v in value.items() for row in flatten(v, f"{prefix}.{k}" if prefix else k)] + if isinstance(value, list): + rows = [] + for i, item in enumerate(value): + if isinstance(item, dict): + id_field = next((f for f in ("key", "path") if item.get(f)), None) + if id_field: + rest = {k: v for k, v in item.items() if k != id_field} + rows.extend(flatten(rest, f"{prefix}[{item[id_field]}]")) + continue + rows.extend(flatten(item, f"{prefix}[{i}]")) + return rows + return [{"name": prefix, "value": value}] + + +@stats_app.callback(invoke_without_command=True) +def show_stats( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Show the statistics of the Fess server: JVM, OS, process, search engine and filesystem. + """ + client = FessAPIClient(Settings()) + try: + result = client.get_stats() + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + typer.echo(format_list_markdown("Stats", flatten(response.get("stats", {})), [ + ("NAME", "name"), + ("VALUE", "value"), + ])) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to retrieve the stats. {message} Status code: {status}", "Stats", "show")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Stats", "show") + raise typer.Exit(code=1) diff --git a/src/fessctl/commands/suggest.py b/src/fessctl/commands/suggest.py new file mode 100644 index 0000000..795826b --- /dev/null +++ b/src/fessctl/commands/suggest.py @@ -0,0 +1,134 @@ +import json + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_detail_markdown, format_result_markdown, output_error + +suggest_app = typer.Typer() + + +def delete_suggest_words(call, action: str, prompt: str, success: str, yes: bool, output: str): + """ + Delete suggest words after confirmation; ``call`` runs the delete on the client. + """ + if not yes: + typer.confirm(prompt, abort=True) + + client = FessAPIClient(Settings()) + try: + result = call(client) + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + typer.echo(format_result_markdown(True, success, "Suggest", action)) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to delete the suggest words. {message} Status code: {status}", "Suggest", action)) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Suggest", action) + raise typer.Exit(code=1) + + +@suggest_app.command("info") +def suggest_info( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Show the number of words in the suggest index (all, from documents, from search queries). + """ + client = FessAPIClient(Settings()) + try: + result = client.get_suggest_info() + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + typer.echo(format_detail_markdown( + "Suggest", + response.get("setting", {}), + [ + ("total_words_num", "total_words_num"), + ("document_words_num", "document_words_num"), + ("query_words_num", "query_words_num"), + ], + )) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to retrieve the suggest info. {message} Status code: {status}", "Suggest", "info")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Suggest", "info") + raise typer.Exit(code=1) + + +@suggest_app.command("delete-all") +def delete_all_suggest_words( + yes: bool = typer.Option( + False, "--yes", "-y", help="Skip the confirmation prompt" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Delete every word from the suggest index. Elevate words are restored by Fess afterwards; + the other words come back the next time the suggest job runs. + """ + delete_suggest_words( + lambda c: c.delete_all_suggest_words(), "delete-all", "Delete all suggest words?", + "All suggest words deleted successfully.", yes, output) + + +@suggest_app.command("delete-document") +def delete_document_suggest_words( + yes: bool = typer.Option( + False, "--yes", "-y", help="Skip the confirmation prompt" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Delete the suggest words that come from documents. + """ + delete_suggest_words( + lambda c: c.delete_document_suggest_words(), "delete-document", "Delete the suggest words from documents?", + "Suggest words from documents deleted successfully.", yes, output) + + +@suggest_app.command("delete-query") +def delete_query_suggest_words( + yes: bool = typer.Option( + False, "--yes", "-y", help="Skip the confirmation prompt" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Delete the suggest words that come from search queries. + """ + delete_suggest_words( + lambda c: c.delete_query_suggest_words(), "delete-query", "Delete the suggest words from search queries?", + "Suggest words from search queries deleted successfully.", yes, output) diff --git a/src/fessctl/commands/systeminfo.py b/src/fessctl/commands/systeminfo.py new file mode 100644 index 0000000..b352e5e --- /dev/null +++ b/src/fessctl/commands/systeminfo.py @@ -0,0 +1,58 @@ +import json + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_list_markdown, format_result_markdown, output_error + +systeminfo_app = typer.Typer() + +# (title, key in the response) of the property groups, in display order +SECTIONS = [ + ("Bug Report", "bug_report_props"), + ("Environment", "env_props"), + ("Fess", "fess_props"), + ("System", "system_props"), +] + + +@systeminfo_app.callback(invoke_without_command=True) +def show_systeminfo( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Show the system information of the Fess server: the bug report, environment, + Fess and system properties. + """ + client = FessAPIClient(Settings()) + try: + result = client.get_systeminfo() + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + typer.echo("\n\n".join( + format_list_markdown(title, response.get(key, []), [ + ("NAME", "label"), + ("VALUE", "value"), + ]) + for title, key in SECTIONS + )) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to retrieve the system information. {message} Status code: {status}", "SystemInfo", "show")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "SystemInfo", "show") + raise typer.Exit(code=1) diff --git a/tests/commands/test_backup.py b/tests/commands/test_backup.py new file mode 100644 index 0000000..67cbbce --- /dev/null +++ b/tests/commands/test_backup.py @@ -0,0 +1,61 @@ +import json + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.backup import backup_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def test_backup_list(runner, fess_service): + result = runner.invoke(backup_app, ["list", "--output", "json"]) + assert result.exit_code == 0, f"List failed: {result.stdout}" + resp = json.loads(result.stdout).get("response", {}) + assert resp.get("status") == 0 + ids = [f["id"] for f in resp.get("files", [])] + for expected in ("fess_config.bulk", "system.properties", "search_log.ndjson"): + assert expected in ids + + result = runner.invoke(backup_app, ["list"]) + assert result.exit_code == 0, f"List failed: {result.stdout}" + assert "| fess_config.bulk | fess_config.bulk |" in result.stdout + + +@pytest.mark.parametrize("backup_id", ["fess_config.bulk", "system.properties", "search_log.ndjson"]) +def test_backup_download(runner, fess_service, tmp_path, backup_id): + """ + An index dump, the system properties and a search log, all ids with an extension. + The search log may be empty, so only the transfer is checked. + """ + dest = tmp_path / backup_id + + result = runner.invoke(backup_app, ["download", backup_id, "--dest", str(dest), "--output", "json"]) + assert result.exit_code == 0, f"Download failed: {result.stdout}" + resp = json.loads(result.stdout).get("response", {}) + assert resp.get("status") == 0 + assert dest.is_file() + assert resp["size"] == dest.stat().st_size + if backup_id == "fess_config.bulk": + assert '"_index":"fess_config' in dest.read_text() + + # An existing destination is not overwritten unless --force is given. + result = runner.invoke(backup_app, ["download", backup_id, "--dest", str(dest)]) + assert result.exit_code != 0 + assert "--force" in result.stdout + result = runner.invoke(backup_app, ["download", backup_id, "--dest", str(dest), "--force"]) + assert result.exit_code == 0, f"Forced download failed: {result.stdout}" + + +def test_backup_download_unknown_id(runner, fess_service, tmp_path): + dest = tmp_path / "no-such-backup" + result = runner.invoke(backup_app, ["download", "no-such-backup", "--dest", str(dest)]) + assert result.exit_code != 0 + assert "failed to download backup file" in result.stdout.lower() + assert not dest.exists() diff --git a/tests/commands/test_log.py b/tests/commands/test_log.py new file mode 100644 index 0000000..1deb1e7 --- /dev/null +++ b/tests/commands/test_log.py @@ -0,0 +1,67 @@ +import json + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.log import log_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def _fess_log(runner): + """ + The `fess.log` entry of the log list. Fess logs to it from startup, so it always exists. + """ + result = runner.invoke(log_app, ["list", "--output", "json"]) + assert result.exit_code == 0, f"List failed: {result.stdout}" + resp = json.loads(result.stdout).get("response", {}) + assert resp.get("status") == 0 + files = resp.get("files", []) + assert all({"name", "id", "lastModified"} <= f.keys() for f in files) + matches = [f for f in files if f["name"] == "fess.log"] + assert matches, f"fess.log is not listed: {[f['name'] for f in files]}" + return matches[0] + + +def test_log_list_text(runner, fess_service): + result = runner.invoke(log_app, ["list"]) + assert result.exit_code == 0, f"List failed: {result.stdout}" + assert "| fess.log |" in result.stdout + + +def test_log_download(runner, fess_service, tmp_path): + entry = _fess_log(runner) + dest = tmp_path / "fess.log" + + result = runner.invoke(log_app, ["download", entry["id"], "--dest", str(dest), "--output", "json"]) + assert result.exit_code == 0, f"Download failed: {result.stdout}" + resp = json.loads(result.stdout).get("response", {}) + assert resp.get("status") == 0 + assert dest.is_file() + assert resp["size"] == dest.stat().st_size + + # An existing destination is not overwritten unless --force is given. + dest.write_text("keep") + result = runner.invoke(log_app, ["download", entry["id"], "--dest", str(dest)]) + assert result.exit_code != 0 + assert "--force" in result.stdout + assert dest.read_text() == "keep" + + result = runner.invoke(log_app, ["download", entry["id"], "--dest", str(dest), "--force"]) + assert result.exit_code == 0, f"Forced download failed: {result.stdout}" + assert dest.read_text() != "keep" + + +def test_log_download_unknown_id(runner, fess_service, tmp_path): + dest = tmp_path / "no-such.log" + # base64 of "no-such.log" + result = runner.invoke(log_app, ["download", "bm8tc3VjaC5sb2c=", "--dest", str(dest)]) + assert result.exit_code != 0 + assert "not found" in result.stdout.lower() + assert not dest.exists() diff --git a/tests/commands/test_stats.py b/tests/commands/test_stats.py new file mode 100644 index 0000000..8e6953b --- /dev/null +++ b/tests/commands/test_stats.py @@ -0,0 +1,33 @@ +import json + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.stats import stats_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def test_stats_json(runner, fess_service): + result = runner.invoke(stats_app, ["--output", "json"]) + assert result.exit_code == 0, f"Stats failed: {result.stdout}" + response = json.loads(result.stdout).get("response", {}) + assert response.get("status") == 0 + stats = response.get("stats", {}) + for key in ("jvm", "os", "process", "engine", "fs"): + assert key in stats, f"{key} is missing" + assert stats["jvm"]["memory"]["heap"]["max"] > 0 + assert stats["engine"]["status"] in ("green", "yellow") + + +def test_stats_text(runner, fess_service): + result = runner.invoke(stats_app, []) + assert result.exit_code == 0, f"Stats failed: {result.stdout}" + assert "| jvm.memory.heap.used |" in result.stdout + assert "| engine.cluster_name |" in result.stdout diff --git a/tests/commands/test_suggest.py b/tests/commands/test_suggest.py new file mode 100644 index 0000000..ec41a2e --- /dev/null +++ b/tests/commands/test_suggest.py @@ -0,0 +1,58 @@ +import json + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.suggest import suggest_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def _word_counts(runner): + result = runner.invoke(suggest_app, ["info", "--output", "json"]) + assert result.exit_code == 0, f"Info failed: {result.stdout}" + resp = json.loads(result.stdout).get("response", {}) + assert resp.get("status") == 0 + setting = resp["setting"] + assert all(isinstance(setting[k], int) for k in ("total_words_num", "document_words_num", "query_words_num")) + return setting + + +def test_suggest_info_text(runner, fess_service): + result = runner.invoke(suggest_app, ["info"]) + assert result.exit_code == 0, f"Info failed: {result.stdout}" + assert "| total_words_num |" in result.stdout + + +@pytest.mark.parametrize("command", ["delete-query", "delete-document", "delete-all"]) +def test_suggest_delete_asks_for_confirmation(runner, fess_service, command): + result = runner.invoke(suggest_app, [command], input="n\n") + assert result.exit_code != 0 + assert "[y/N]" in result.stdout + + +def test_suggest_delete(runner, fess_service): + """ + The suggest index holds nothing the other tests rely on, so every kind can be deleted; + the counts of a deleted kind are 0 afterwards (the suggest job may refill the others). + """ + result = runner.invoke(suggest_app, ["delete-query", "--yes", "--output", "json"]) + assert result.exit_code == 0, f"Delete query failed: {result.stdout}" + assert json.loads(result.stdout).get("response", {}).get("status") == 0 + assert _word_counts(runner)["query_words_num"] == 0 + + result = runner.invoke(suggest_app, ["delete-document", "--yes"]) + assert result.exit_code == 0, f"Delete document failed: {result.stdout}" + assert "deleted successfully" in result.stdout + assert _word_counts(runner)["document_words_num"] == 0 + + result = runner.invoke(suggest_app, ["delete-all", "--yes"]) + assert result.exit_code == 0, f"Delete all failed: {result.stdout}" + assert "deleted successfully" in result.stdout + assert _word_counts(runner)["total_words_num"] == 0 diff --git a/tests/commands/test_systeminfo.py b/tests/commands/test_systeminfo.py new file mode 100644 index 0000000..e002848 --- /dev/null +++ b/tests/commands/test_systeminfo.py @@ -0,0 +1,34 @@ +import json + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.systeminfo import systeminfo_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def test_systeminfo_json(runner, fess_service): + result = runner.invoke(systeminfo_app, ["--output", "json"]) + assert result.exit_code == 0, f"Systeminfo failed: {result.stdout}" + response = json.loads(result.stdout).get("response", {}) + assert response.get("status") == 0 + for key in ("bug_report_props", "env_props", "fess_props", "system_props"): + props = response.get(key) + assert isinstance(props, list) and props, f"{key} is empty" + assert all("label" in p and "value" in p for p in props) + assert "java.runtime.version" in [p["label"] for p in response["bug_report_props"]] + + +def test_systeminfo_text(runner, fess_service): + result = runner.invoke(systeminfo_app, []) + assert result.exit_code == 0, f"Systeminfo failed: {result.stdout}" + for title in ("## Bug Report", "## Environment", "## Fess", "## System"): + assert title in result.stdout + assert "| java.runtime.version |" in result.stdout diff --git a/tests/unit/test_backup.py b/tests/unit/test_backup.py new file mode 100644 index 0000000..7e0930f --- /dev/null +++ b/tests/unit/test_backup.py @@ -0,0 +1,87 @@ +""" +Unit tests for the backup command. +""" +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.backup import backup_app + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestBackupList: + @patch("fessctl.commands.backup.FessAPIClient") + def test_list_text_output(self, mock_client_class, runner): + mock_client_class.return_value.get_backup_files.return_value = {"response": {"status": 0, "total": 1, "files": [ + {"name": "fess_config.bulk", "id": "fess_config.bulk"}, + ]}} + + result = runner.invoke(backup_app, ["list"]) + + assert result.exit_code == 0 + assert "| fess_config.bulk | fess_config.bulk |" in result.stdout + + @patch("fessctl.commands.backup.FessAPIClient") + def test_list_failure_exits_with_status(self, mock_client_class, runner): + mock_client_class.return_value.get_backup_files.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + + result = runner.invoke(backup_app, ["list"]) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + +class TestBackupDownload: + @patch("fessctl.commands.backup.FessAPIClient") + def test_download_success(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_backup_file.return_value = {"response": {"status": 0, "path": "x", "size": 0}} + mock_client_class.return_value = mock_client + dest = tmp_path / "search_log.ndjson" + + result = runner.invoke(backup_app, ["download", "search_log.ndjson", "--dest", str(dest)]) + + assert result.exit_code == 0 + assert "(0 bytes)" in result.stdout + mock_client.download_backup_file.assert_called_once_with("search_log.ndjson", dest) + + @patch("fessctl.commands.backup.FessAPIClient") + def test_download_unknown_id_exits_with_status(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_backup_file.return_value = {"response": {"status": 1, "message": "Could not find any backup index."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(backup_app, ["download", "nope", "-d", str(tmp_path / "nope")]) + + assert result.exit_code == 1 + assert "Could not find any backup index." in result.stdout + + @patch("fessctl.commands.backup.FessAPIClient") + def test_download_refuses_to_overwrite(self, mock_client_class, runner, tmp_path): + dest = tmp_path / "fess.json" + dest.write_text("keep") + + result = runner.invoke(backup_app, ["download", "fess.json", "--dest", str(dest)]) + + assert result.exit_code == 1 + assert "--force" in result.stdout + assert dest.read_text() == "keep" + mock_client_class.return_value.download_backup_file.assert_not_called() + + @patch("fessctl.commands.backup.FessAPIClient") + def test_download_force_overwrites(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_backup_file.return_value = {"response": {"status": 0, "path": "x", "size": 3}} + mock_client_class.return_value = mock_client + dest = tmp_path / "fess.json" + dest.write_text("old") + + result = runner.invoke(backup_app, ["download", "fess.json", "--dest", str(dest), "-f"]) + + assert result.exit_code == 0 + mock_client.download_backup_file.assert_called_once_with("fess.json", dest) diff --git a/tests/unit/test_client_downloads.py b/tests/unit/test_client_downloads.py new file mode 100644 index 0000000..a182476 --- /dev/null +++ b/tests/unit/test_client_downloads.py @@ -0,0 +1,118 @@ +""" +Unit tests for the file downloads (log and backup) of fessctl.api.client, run against a local HTTP server. +""" +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from unittest.mock import Mock + +import pytest + +from fessctl.api.client import FessAPIClient, FessAPIClientError +from fessctl.config.settings import Settings + +# path -> (status, content type, body) of what the fake Fess answers +ROUTES = { + "/api/admin/log/file/ZmVzcy5sb2c=": (200, "application/octet-stream", b"log line\n"), + "/api/admin/log/file/YmFk": (400, "application/json;charset=UTF-8", b'{"response":{"message":"Could not find bad.","status":1}}'), + # an id with an extension is only served with the trailing slash + "/api/admin/backup/file/fess_config.bulk/": (200, "application/octet-stream", b'{"index":{}}\n{"a":1}\n'), + "/api/admin/backup/file/search_log.ndjson/": (200, "application/x-ndjson", b'{"search-word":"x"}\n'), + "/api/admin/backup/file/nope/": (400, "application/json;charset=UTF-8", b'{"response":{"message":"Could not find any backup index.","status":1}}'), +} + + +class Handler(BaseHTTPRequestHandler): + requests = [] + + def do_GET(self): + Handler.requests.append((self.path, self.headers.get("Authorization"))) + status, content_type, body = ROUTES.get(self.path, (404, "text/plain;charset=UTF-8", b"Not Found.")) + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +@pytest.fixture(scope="module") +def endpoint(): + server = HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + yield f"http://127.0.0.1:{server.server_port}" + server.shutdown() + + +@pytest.fixture +def client(endpoint): + settings = Mock(spec=Settings) + settings.fess_endpoint = endpoint + settings.access_token = "test-token" + settings.fess_version = "15.9.0" + Handler.requests.clear() + return FessAPIClient(settings) + + +def test_download_log_file_writes_the_body(client, tmp_path): + dest = tmp_path / "fess.log" + + result = client.download_log_file("ZmVzcy5sb2c=", dest) + + assert result == {"response": {"status": 0, "path": str(dest), "size": 9}} + assert dest.read_bytes() == b"log line\n" + assert list(tmp_path.iterdir()) == [dest] + assert Handler.requests == [("/api/admin/log/file/ZmVzcy5sb2c=", "Bearer test-token")] + + +def test_download_backup_file_requests_the_id_with_a_trailing_slash(client, tmp_path): + dest = tmp_path / "fess_config.bulk" + + result = client.download_backup_file("fess_config.bulk", dest) + + assert result["response"]["status"] == 0 + assert dest.read_bytes() == b'{"index":{}}\n{"a":1}\n' + assert Handler.requests == [("/api/admin/backup/file/fess_config.bulk/", "Bearer test-token")] + + +def test_download_backup_ndjson_is_not_mistaken_for_a_json_error(client, tmp_path): + dest = tmp_path / "search_log.ndjson" + + result = client.download_backup_file("search_log.ndjson", dest) + + assert result["response"]["status"] == 0 + assert dest.read_bytes() == b'{"search-word":"x"}\n' + + +def test_download_json_error_is_returned_and_writes_no_file(client, tmp_path): + dest = tmp_path / "nope" + + result = client.download_backup_file("nope", dest) + + assert result == {"response": {"message": "Could not find any backup index.", "status": 1}} + assert list(tmp_path.iterdir()) == [] + + +def test_download_non_json_error_raises(client, tmp_path): + dest = tmp_path / "x.log" + + with pytest.raises(FessAPIClientError) as e: + client.download_log_file("eA==", dest) + + assert e.value.status_code == 404 + assert "Not Found." in str(e.value) + assert list(tmp_path.iterdir()) == [] + + +def test_download_unreachable_server_raises_and_writes_no_file(tmp_path): + settings = Mock(spec=Settings) + settings.fess_endpoint = "http://127.0.0.1:1" + settings.access_token = "test-token" + settings.fess_version = "15.9.0" + + with pytest.raises(FessAPIClientError) as e: + FessAPIClient(settings).download_log_file("ZmVzcy5sb2c=", tmp_path / "x.log") + + assert e.value.status_code == -1 + assert list(tmp_path.iterdir()) == [] diff --git a/tests/unit/test_client_ops.py b/tests/unit/test_client_ops.py new file mode 100644 index 0000000..3eecc32 --- /dev/null +++ b/tests/unit/test_client_ops.py @@ -0,0 +1,49 @@ +""" +Unit tests for the systeminfo, stats, log, backup and suggest listing/deleting methods of fessctl.api.client. +""" +from unittest.mock import Mock, patch + +import pytest + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings + + +@pytest.fixture +def client(): + settings = Mock(spec=Settings) + settings.fess_endpoint = "http://localhost:8080" + settings.access_token = "test-token" + settings.fess_version = "15.9.0" + return FessAPIClient(settings) + + +@pytest.mark.parametrize("method,path", [ + ("get_systeminfo", "/api/admin/systeminfo"), + ("get_stats", "/api/admin/stats"), + ("get_log_files", "/api/admin/log/files"), + ("get_backup_files", "/api/admin/backup/files"), + ("get_suggest_info", "/api/admin/suggest"), +]) +@patch("httpx.get") +def test_get_methods_request_the_path(mock_get, client, method, path): + mock_get.return_value.json.return_value = {"response": {"status": 0}} + + assert getattr(client, method)() == {"response": {"status": 0}} + + assert mock_get.call_args[0][0] == f"http://localhost:8080{path}" + assert mock_get.call_args[1]["headers"]["Authorization"] == "Bearer test-token" + + +@pytest.mark.parametrize("method,path", [ + ("delete_all_suggest_words", "/api/admin/suggest/all"), + ("delete_document_suggest_words", "/api/admin/suggest/document"), + ("delete_query_suggest_words", "/api/admin/suggest/query"), +]) +@patch("httpx.delete") +def test_suggest_delete_methods_request_the_path(mock_delete, client, method, path): + mock_delete.return_value.json.return_value = {"response": {"status": 0}} + + assert getattr(client, method)() == {"response": {"status": 0}} + + assert mock_delete.call_args[0][0] == f"http://localhost:8080{path}" diff --git a/tests/unit/test_log.py b/tests/unit/test_log.py new file mode 100644 index 0000000..3a466aa --- /dev/null +++ b/tests/unit/test_log.py @@ -0,0 +1,128 @@ +""" +Unit tests for the log command. +""" +import json +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.api.client import FessAPIClientError +from fessctl.commands.log import log_app + +FILES = {"response": {"status": 0, "total": 2, "files": [ + {"name": "fess.log", "id": "ZmVzcy5sb2c=", "lastModified": "2026-10-16T12:23:38"}, + {"name": "audit.log", "id": "YXVkaXQubG9n", "lastModified": "2026-10-16T12:23:49"}, +]}} + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestLogList: + @patch("fessctl.commands.log.FessAPIClient") + def test_list_text_output(self, mock_client_class, runner): + mock_client_class.return_value.get_log_files.return_value = FILES + + result = runner.invoke(log_app, ["list"]) + + assert result.exit_code == 0 + assert "| fess.log | ZmVzcy5sb2c= | 2026-10-16T12:23:38 |" in result.stdout + + @patch("fessctl.commands.log.FessAPIClient") + def test_list_empty(self, mock_client_class, runner): + mock_client_class.return_value.get_log_files.return_value = {"response": {"status": 0, "files": []}} + + result = runner.invoke(log_app, ["list"]) + + assert result.exit_code == 0 + assert "No Log files found." in result.stdout + + @patch("fessctl.commands.log.FessAPIClient") + def test_list_failure_exits_with_status(self, mock_client_class, runner): + mock_client_class.return_value.get_log_files.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + + result = runner.invoke(log_app, ["list"]) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + +class TestLogDownload: + @patch("fessctl.commands.log.FessAPIClient") + def test_download_success(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.get_log_files.return_value = FILES + mock_client.download_log_file.return_value = {"response": {"status": 0, "path": "x", "size": 12}} + mock_client_class.return_value = mock_client + dest = tmp_path / "fess.log" + + result = runner.invoke(log_app, ["download", "ZmVzcy5sb2c=", "--dest", str(dest)]) + + assert result.exit_code == 0 + assert "downloaded" in result.stdout + assert "(12 bytes)" in result.stdout + mock_client.download_log_file.assert_called_once_with("ZmVzcy5sb2c=", dest) + + @patch("fessctl.commands.log.FessAPIClient") + def test_download_unknown_id_is_not_requested(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.get_log_files.return_value = FILES + mock_client_class.return_value = mock_client + + result = runner.invoke(log_app, ["download", "bm9wZS5sb2c=", "--dest", str(tmp_path / "x.log")]) + + assert result.exit_code == 1 + assert "not found" in result.stdout + mock_client.download_log_file.assert_not_called() + + @patch("fessctl.commands.log.FessAPIClient") + def test_download_refuses_to_overwrite(self, mock_client_class, runner, tmp_path): + dest = tmp_path / "fess.log" + dest.write_text("keep") + + result = runner.invoke(log_app, ["download", "ZmVzcy5sb2c=", "--dest", str(dest)]) + + assert result.exit_code == 1 + assert "--force" in result.stdout + assert dest.read_text() == "keep" + mock_client_class.return_value.download_log_file.assert_not_called() + + @patch("fessctl.commands.log.FessAPIClient") + def test_download_force_overwrites(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.get_log_files.return_value = FILES + mock_client.download_log_file.return_value = {"response": {"status": 0, "path": "x", "size": 1}} + mock_client_class.return_value = mock_client + dest = tmp_path / "fess.log" + dest.write_text("old") + + result = runner.invoke(log_app, ["download", "ZmVzcy5sb2c=", "--dest", str(dest), "--force"]) + + assert result.exit_code == 0 + mock_client.download_log_file.assert_called_once_with("ZmVzcy5sb2c=", dest) + + @patch("fessctl.commands.log.FessAPIClient") + def test_download_server_error_exits_with_status(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.get_log_files.return_value = FILES + mock_client.download_log_file.return_value = {"response": {"status": 1, "message": "Could not find x."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(log_app, ["download", "ZmVzcy5sb2c=", "-d", str(tmp_path / "x.log")]) + + assert result.exit_code == 1 + assert "Could not find x." in result.stdout + + @patch("fessctl.commands.log.FessAPIClient") + def test_download_client_error_json_output(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.get_log_files.side_effect = FessAPIClientError(-1, "Network error: refused") + mock_client_class.return_value = mock_client + + result = runner.invoke(log_app, ["download", "ZmVzcy5sb2c=", "-d", str(tmp_path / "x.log"), "-o", "json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout)["status"] == "error" diff --git a/tests/unit/test_stats.py b/tests/unit/test_stats.py new file mode 100644 index 0000000..8ef14e8 --- /dev/null +++ b/tests/unit/test_stats.py @@ -0,0 +1,70 @@ +""" +Unit tests for the stats command. +""" +import json +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.stats import flatten, stats_app + +RESPONSE = {"response": {"status": 0, "stats": { + "jvm": { + "memory": {"heap": {"used": 10, "max": 20}}, + "gc": [{"key": "young", "count": 3, "time": 7}], + }, + "os": {"load_averages": [0.5, 0.25]}, + "fs": [{"path": "/", "free": 1, "total": 2}], +}}} + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_flatten_names_rows_by_path_and_list_item_key(): + rows = flatten(RESPONSE["response"]["stats"]) + + assert {r["name"]: r["value"] for r in rows} == { + "jvm.memory.heap.used": 10, + "jvm.memory.heap.max": 20, + "jvm.gc[young].count": 3, + "jvm.gc[young].time": 7, + "os.load_averages[0]": 0.5, + "os.load_averages[1]": 0.25, + "fs[/].free": 1, + "fs[/].total": 2, + } + + +@patch("fessctl.commands.stats.FessAPIClient") +def test_text_output(mock_client_class, runner): + mock_client_class.return_value.get_stats.return_value = RESPONSE + + result = runner.invoke(stats_app, []) + + assert result.exit_code == 0 + assert "| jvm.memory.heap.used | 10 |" in result.stdout + assert "| fs[/].free | 1 |" in result.stdout + + +@patch("fessctl.commands.stats.FessAPIClient") +def test_json_output_is_the_response(mock_client_class, runner): + mock_client_class.return_value.get_stats.return_value = RESPONSE + + result = runner.invoke(stats_app, ["-o", "json"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == RESPONSE + + +@patch("fessctl.commands.stats.FessAPIClient") +def test_failure_exits_with_status(mock_client_class, runner): + mock_client_class.return_value.get_stats.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + + result = runner.invoke(stats_app, []) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout diff --git a/tests/unit/test_suggest.py b/tests/unit/test_suggest.py new file mode 100644 index 0000000..db4e798 --- /dev/null +++ b/tests/unit/test_suggest.py @@ -0,0 +1,90 @@ +""" +Unit tests for the suggest command. +""" +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.suggest import suggest_app + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestSuggestInfo: + @patch("fessctl.commands.suggest.FessAPIClient") + def test_info_text_output(self, mock_client_class, runner): + mock_client_class.return_value.get_suggest_info.return_value = {"response": {"status": 0, "setting": { + "total_words_num": 7, "document_words_num": 5, "query_words_num": 2}}} + + result = runner.invoke(suggest_app, ["info"]) + + assert result.exit_code == 0 + assert "| total_words_num | 7 |" in result.stdout + assert "| document_words_num | 5 |" in result.stdout + assert "| query_words_num | 2 |" in result.stdout + + @patch("fessctl.commands.suggest.FessAPIClient") + def test_info_failure_exits_with_status(self, mock_client_class, runner): + mock_client_class.return_value.get_suggest_info.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + + result = runner.invoke(suggest_app, ["info"]) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + +DELETES = [ + ("delete-all", "delete_all_suggest_words", "All suggest words deleted"), + ("delete-document", "delete_document_suggest_words", "Suggest words from documents deleted"), + ("delete-query", "delete_query_suggest_words", "Suggest words from search queries deleted"), +] + + +class TestSuggestDelete: + @pytest.mark.parametrize("command,method,message", DELETES) + @patch("fessctl.commands.suggest.FessAPIClient") + def test_delete_aborts_without_confirmation(self, mock_client_class, command, method, message, runner): + result = runner.invoke(suggest_app, [command], input="n\n") + + assert result.exit_code != 0 + getattr(mock_client_class.return_value, method).assert_not_called() + + @pytest.mark.parametrize("command,method,message", DELETES) + @patch("fessctl.commands.suggest.FessAPIClient") + def test_delete_with_yes(self, mock_client_class, command, method, message, runner): + mock_client = Mock() + getattr(mock_client, method).return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + + result = runner.invoke(suggest_app, [command, "--yes"]) + + assert result.exit_code == 0 + assert message in result.stdout + getattr(mock_client, method).assert_called_once_with() + + @pytest.mark.parametrize("command,method,message", DELETES) + @patch("fessctl.commands.suggest.FessAPIClient") + def test_delete_confirmed_at_the_prompt(self, mock_client_class, command, method, message, runner): + mock_client = Mock() + getattr(mock_client, method).return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + + result = runner.invoke(suggest_app, [command], input="y\n") + + assert result.exit_code == 0 + getattr(mock_client, method).assert_called_once_with() + + @pytest.mark.parametrize("command,method,message", DELETES) + @patch("fessctl.commands.suggest.FessAPIClient") + def test_delete_failure_exits_with_status(self, mock_client_class, command, method, message, runner): + mock_client = Mock() + getattr(mock_client, method).return_value = {"response": {"status": 1, "message": "Failed to delete."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(suggest_app, [command, "-y", "-o", "json"]) + + assert result.exit_code == 1 diff --git a/tests/unit/test_systeminfo.py b/tests/unit/test_systeminfo.py new file mode 100644 index 0000000..4ddf713 --- /dev/null +++ b/tests/unit/test_systeminfo.py @@ -0,0 +1,75 @@ +""" +Unit tests for the systeminfo command. +""" +import json +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.api.client import FessAPIClientError +from fessctl.commands.systeminfo import systeminfo_app + +RESPONSE = {"response": { + "status": 0, + "bug_report_props": [{"label": "os.name", "value": "Linux"}], + "env_props": [{"label": "FESS_PORT", "value": "8080"}], + "fess_props": [{"label": "domain.title", "value": "Fess"}], + "system_props": [{"label": "java.specification.version", "value": "21"}], +}} + + +@pytest.fixture +def runner(): + return CliRunner() + + +@patch("fessctl.commands.systeminfo.FessAPIClient") +def test_text_output_lists_each_group(mock_client_class, runner): + mock_client_class.return_value.get_systeminfo.return_value = RESPONSE + + result = runner.invoke(systeminfo_app, []) + + assert result.exit_code == 0 + for line in ("## Bug Report", "| os.name | Linux |", "## Environment", "| FESS_PORT | 8080 |", + "## Fess", "| domain.title | Fess |", "## System", "| java.specification.version | 21 |"): + assert line in result.stdout + + +@patch("fessctl.commands.systeminfo.FessAPIClient") +def test_json_output_is_the_response(mock_client_class, runner): + mock_client_class.return_value.get_systeminfo.return_value = RESPONSE + + result = runner.invoke(systeminfo_app, ["--output", "json"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == RESPONSE + + +@patch("fessctl.commands.systeminfo.FessAPIClient") +def test_failure_exits_with_status(mock_client_class, runner): + mock_client_class.return_value.get_systeminfo.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + + result = runner.invoke(systeminfo_app, []) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + +@patch("fessctl.commands.systeminfo.FessAPIClient") +def test_failure_exits_non_zero_in_json_mode(mock_client_class, runner): + mock_client_class.return_value.get_systeminfo.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + + result = runner.invoke(systeminfo_app, ["-o", "json"]) + + assert result.exit_code == 3 + + +@patch("fessctl.commands.systeminfo.FessAPIClient") +def test_client_error_exits_1(mock_client_class, runner): + mock_client_class.return_value.get_systeminfo.side_effect = FessAPIClientError(-1, "Network error: refused") + + result = runner.invoke(systeminfo_app, []) + + assert result.exit_code == 1 + assert "Network error: refused" in result.stdout