diff --git a/skills/fessctl/SKILL.md b/skills/fessctl/SKILL.md index a77f63e..c5f6cad 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 31 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 34 resource types and cross-feature workflows like initial crawl setup. license: Apache-2.0 version: 0.1.0 --- @@ -61,6 +61,9 @@ Each file documents one Fess admin feature: what it is, when to use it, fessctl | Label types | references/features/labeltype.md | | User tags | references/features/tagtype.md | | Themes | references/features/theme.md | +| General settings | references/features/general.md | +| Plugins | references/features/plugin.md | +| Object storage | references/features/storage.md | | Dictionaries | references/features/dict.md | | Key match | references/features/keymatch.md | | Boost document | references/features/boostdoc.md | diff --git a/skills/fessctl/references/features/general.md b/skills/fessctl/references/features/general.md new file mode 100644 index 0000000..8133c3b --- /dev/null +++ b/skills/fessctl/references/features/general.md @@ -0,0 +1,53 @@ +# General settings (fessctl `general`) + +## What it is +The General page of the Admin UI (**System → General**): crawl and purge defaults, search-log and user-info switches, LDAP, notification text, log levels, object-storage connection, RAG chat and the SSO provider settings (OpenID Connect, SAML, SPNEGO, Entra ID). The admin API exposes the whole page as one flat object of snake_case keys. + +Available in every supported Fess version (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). The set of keys grows with the version (`get` returned 37 keys on 14.19.2, 100 on 15.8.0 and 102 on 15.9.0; six LDAP keys are left out while unset); `get` shows exactly what the server under test has. Update is `POST` on 14.x and `PUT` on 15.x; fessctl picks the method. + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `get` | Show every setting. | none | +| `update` | Change settings; every setting not given keeps its value. | one or more `--set KEY=VALUE` | + +Always reconfirm with `fessctl general --help`. + +## How update works +`update` first reads the current settings, then sends the four fields the server requires on every request (`day_for_cleanup`, `crawling_thread_count`, `failure_count_threshold`, `csv_file_encoding`, with their current values) plus only the keys given with `--set`. The server merges the request into the stored settings (a key that is absent or null keeps its value), so a one-key change leaves all other settings as they were; read back with `get` they are identical. + +- `KEY` must be a name `get` shows (the LDAP keys `ldap_provider_url`, `ldap_security_principal`, `ldap_admin_security_principal`, `ldap_admin_security_credentials`, `ldap_base_dn`, `ldap_account_filter` are left out of `get` while unset but can be set). An unknown key is rejected locally, because the server would accept it and ignore it silently. +- A setting that is an integer in `get` takes an integer; checkbox-like settings are the strings `true` / `false`. +- The value after the first `=` is taken literally, so it may contain `=`. + +## Resource JSON shape +```json +{ + "response": { + "setting": { + "day_for_cleanup": 3, + "crawling_thread_count": 5, + "failure_count_threshold": -1, + "csv_file_encoding": "UTF-8", + "login_required": "false", + "purge_job_log_day": -1, + "storage_secret_key": "**********" + }, + "version": "15.9", + "status": 0 + } +} +``` + +## Gotchas +- Secrets (`storage_access_key`, `storage_secret_key`, `oic_client_id`, `oic_client_secret`, `entraid_client_id`, `entraid_client_secret`, `spnego_preauth_password`) are masked as `**********` by `get` (`ldap_admin_security_credentials` is never returned at all) and are never sent back by `update`. A new value replaces the secret; a value made only of `*` is ignored by the server. A secret cannot be cleared through the API (an empty value is ignored); the exception is `spnego_preauth_password`, where an empty value removes it. +- There is no way to remove a key: an empty value (`--set key=`) stores an empty string. For the unset LDAP keys that turns "absent" into "empty". This differs from the Admin UI, where a blank field removes the system property. +- Bad values are rejected by the server with a message and a non-zero exit (for example `day_for_cleanup` above 1000, or from 15.8 on `spnego_prompt_ntlm=true` together with `spnego_allow_basic=false` when `sso_type=spnego`); nothing is changed in that case. + +## Examples +```bash +fessctl general get +fessctl general get --output json | jq -r '.response.setting.crawling_user_agent' +fessctl general update --set purge_job_log_day=30 --set notification_login="Maintenance tonight" +fessctl general update --set storage_endpoint=http://s3.example.com:4566 --set storage_access_key=KEY --set storage_secret_key=SECRET --set storage_bucket=fess +``` diff --git a/skills/fessctl/references/features/plugin.md b/skills/fessctl/references/features/plugin.md new file mode 100644 index 0000000..a23221d --- /dev/null +++ b/skills/fessctl/references/features/plugin.md @@ -0,0 +1,45 @@ +# Plugins (fessctl `plugin`) + +## What it is +Plugins are jars (data stores, ingest, script engines, web apps, thumbnail generators, crawlers, LLM clients, and from 15.9 object storage and SSO) that Fess downloads from its plugin repositories into `WEB-INF/plugin`. The Admin UI manages them under **System → Plugin**. + +Available in every supported Fess version (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). Install is `PUT` on 14.x and `POST` on 15.x; fessctl picks the method. The versions on offer follow the Fess line (for example `15.9.0` plugins on Fess 15.9.0). + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `installed` | List the installed plugins. | none | +| `available` | List what the plugin repositories offer. | none | +| `install` | Ask Fess to download and install one plugin. | ` ` | +| `uninstall` | Ask Fess to delete one plugin jar. Asks for confirmation unless `--yes` is given. | ` ` | + +Always reconfirm with `fessctl plugin --help`. + +## Resource JSON shape +```json +{ + "response": { + "plugins": [ + {"name": "fess-ds-csv", "id": "fess-ds-csv:15.9.0", "type": "fess-ds", "version": "15.9.0", + "url": "https://maven.codelibs.org/release/org/codelibs/fess/fess-ds-csv/15.9.0/fess-ds-csv-15.9.0.jar"} + ], + "status": 0 + } +} +``` +`installed` entries have no `url`. + +## Gotchas +- `install` and `uninstall` only report that the request was accepted: Fess downloads or deletes the jar in a background thread and logs any failure instead of reporting it. Check `plugin installed` afterwards (it can take a few seconds), and a plugin that is not installed can still be "uninstalled" successfully. +- A new plugin is active only after Fess is restarted; so is the removal of one. +- `install` makes the Fess server download the jar, so the server needs outbound access to the plugin repository. A name/version pair that is not in `available` is rejected with `invalid name or version`. +- Installing a plugin also deletes other installed versions of the same plugin. +- `uninstall` needs the exact installed version; check `plugin installed`. + +## Examples +```bash +fessctl plugin available --output json | jq -r '.response.plugins[] | select(.type == "fess-ds") | .id' +fessctl plugin install fess-ds-csv 15.9.0 +fessctl plugin installed +fessctl plugin uninstall fess-ds-csv 15.9.0 --yes +``` diff --git a/skills/fessctl/references/features/storage.md b/skills/fessctl/references/features/storage.md new file mode 100644 index 0000000..3b459d4 --- /dev/null +++ b/skills/fessctl/references/features/storage.md @@ -0,0 +1,52 @@ +# Object storage (fessctl `storage`) + +## What it is +The storage browser of the Admin UI (**System → Storage**): list, download, upload and delete objects in the S3-compatible or Google Cloud Storage bucket that Fess is configured to use (`storage_*` keys of `fessctl general`). + +Every supported version has the API (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0), with two differences: +- **Upload does not work on 15.6.1 to 15.8.0.** Those releases declare the upload endpoint as `PUT` only and do not parse a multipart `PUT`; the server answers `file is required.`, so fessctl refuses with a message instead of sending the request. Upload works on 14.19.2 (`POST`) and 15.9.0+ (`POST`). +- **An unreachable or unconfigured storage is reported only from 15.9.0.** Before that `list` answers an empty list (`No Storage items found.`) whatever is wrong. 15.9.0 returns an error such as `Storage Access Error: Access key ID cannot be blank.`, or, for a `storage_type` no plugin serves, `No storage client is registered as StorageClient ... install fess-storage-s3 ... or fess-storage-gcs`. In 15.9.0 the S3 and GCS clients are the `fess-storage-s3` / `fess-storage-gcs` plugins (bundled in the 15.9.0 distribution; see `fessctl plugin installed`). + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `list` | List objects and directories under a path. | optional `[PATH]` (default: bucket root) | +| `download` | Write an object to `--file`, or to standard output without it. An existing `--file` is kept unless `--force`. | `` | +| `upload` | Upload a local file as `<--dir>/` (default: bucket root). An existing object of that name is overwritten. | `` | +| `delete` | Delete one object. Asks for confirmation unless `--yes` is given. | `` | + +Always reconfirm with `fessctl storage --help`. + +## Paths +Commands take the plain object path (`docs/2026/report.pdf`, no leading slash); `list` prints the same form in its PATH column. The API itself addresses an object by the URL-safe Base64 of that path (`ZG9jcy8yMDI2L3JlcG9ydC5wZGY=`), which fessctl computes. Directories are prefixes: there is nothing to create or delete. + +## Resource JSON shape +```json +{ + "response": { + "items": [ + {"id": "ZG9jcy8=", "name": "docs", "directory": true, "size": 0, "hashCode": 348235201}, + {"id": "ZG9jcy9hLnR4dA==", "path": "docs", "name": "a.txt", "directory": false, "size": 6, + "lastModified": "2026-10-16T12:28:45Z", "hashCode": 755113370} + ], + "status": 0 + } +} +``` +Item keys are camelCase here (`lastModified`, `hashCode`); `path` is absent for items at the bucket root. + +## Gotchas +- `download` cannot tell a failure from success by status: Fess streams the object after it has committed a `200`, so a missing object or unreachable storage comes back as `200` with `Content-Length: 0`. fessctl treats that as an error. A real empty object is sent chunked and is written as a 0-byte file. +- `delete` of an object that does not exist succeeds (S3 semantics). +- `list` returns at most `storage.max.items.in.page` items (default 1000); there is no paging. +- Uploaded content goes through multipart `file` plus `path`; names are kept as given. + +## Examples +```bash +fessctl storage list +fessctl storage list docs/2026 +fessctl storage download docs/2026/report.pdf --file ./report.pdf +fessctl storage download docs/notes.txt | head +fessctl storage upload ./report.pdf --dir docs/2026 +fessctl storage delete docs/2026/report.pdf --yes +``` diff --git a/skills/fessctl/references/workflows.md b/skills/fessctl/references/workflows.md index 69762f8..7e269c2 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`, `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. +These recipes intentionally restrict themselves to features fessctl currently wraps. Operations that depend on `searchlist` 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 f442ff8..c377290 100644 --- a/src/fessctl/api/client.py +++ b/src/fessctl/api/client.py @@ -7,6 +7,7 @@ import httpx from fessctl.config.settings import Settings +from fessctl.utils import encode_to_urlsafe_base64 class Action(Enum): @@ -108,9 +109,15 @@ def send_request( url, headers=headers, json=json_data, params=params, timeout=self.timeout ) elif action == Action.DELETE: - response = httpx.delete( - url, headers=headers, params=params, timeout=self.timeout - ) + if json_data is None: + response = httpx.delete( + url, headers=headers, params=params, timeout=self.timeout + ) + else: + # httpx.delete cannot carry a body; some endpoints (plugin) bind it. + response = httpx.request( + "DELETE", url, headers=headers, params=params, json=json_data, timeout=self.timeout + ) elif action == Action.LIST: # Fess admin list endpoints bind size, page and filters from the # JSON request body and ignore the query string, so send both. @@ -146,6 +153,9 @@ def send_request( content=f"Network error: {str(e)}" ) from e + return self._parse_json(response) + + def _parse_json(self, response) -> dict: # Parse JSON response - Fess API returns JSON even for errors try: return response.json() @@ -863,6 +873,114 @@ 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}) + # General APIs + + def get_general(self) -> dict: + """ + Retrieves the General system settings. + """ + url = f"{self.base_url}/api/admin/general" + return self.send_request(Action.GET, url) + + def update_general(self, setting: dict) -> dict: + """ + Updates the General system settings. The server merges the request with the stored + settings: a key that is left out (or null) keeps its stored value. + """ + url = f"{self.base_url}/api/admin/general" + return self.send_request(Action.EDIT, url, json_data=setting) + + # Plugin APIs + + def get_installed_plugins(self) -> dict: + """ + Retrieves the installed plugins. + """ + url = f"{self.base_url}/api/admin/plugin/installed" + return self.send_request(Action.GET, url) + + def get_available_plugins(self) -> dict: + """ + Retrieves the plugins that can be installed. + """ + url = f"{self.base_url}/api/admin/plugin/available" + return self.send_request(Action.GET, url) + + def install_plugin(self, name: str, version: str) -> dict: + """ + Installs a plugin by name and version. + """ + url = f"{self.base_url}/api/admin/plugin" + return self.send_request(Action.CREATE, url, json_data={"name": name, "version": version}) + + def uninstall_plugin(self, name: str, version: str) -> dict: + """ + Uninstalls a plugin by name and version (the request carries a JSON body). + """ + url = f"{self.base_url}/api/admin/plugin" + return self.send_request(Action.DELETE, url, json_data={"name": name, "version": version}) + + # Storage APIs + # The storage endpoints identify an object by the URL-safe Base64 of its path ("dir/file"). + + def get_storage_items(self, path: str = "") -> dict: + """ + Lists the objects and directories under a path (the bucket root when empty). + """ + url = f"{self.base_url}/api/admin/storage/list" + if path: + url = f"{url}/{encode_to_urlsafe_base64(path)}" + return self.send_request(Action.GET, url) + + def download_storage_file(self, path: str) -> httpx.Response: + """ + Downloads an object. Returns the raw response: the body is the object on success and a + JSON error otherwise, so the caller decides by Content-Type. + """ + url = f"{self.base_url}/api/admin/storage/download/{encode_to_urlsafe_base64(path)}/" + try: + return httpx.get(url, headers=self.admin_api_headers, timeout=self.timeout) + except httpx.RequestError as e: + raise FessAPIClientError( + status_code=-1, + content=f"Network error: {str(e)}" + ) from e + + def delete_storage_file(self, path: str) -> dict: + """ + Deletes an object. + """ + url = f"{self.base_url}/api/admin/storage/delete/{encode_to_urlsafe_base64(path)}/" + return self.send_request(Action.DELETE, url) + + def upload_storage_file(self, file_path: Path, directory: str = "") -> dict: + """ + Uploads a local file as multipart/form-data into a directory of the storage. + + Fess 15.6 to 15.8 declare the endpoint as PUT only and do not parse a multipart PUT + (the server answers "file is required."), so no request is sent there. + """ + if (15, 6) <= (self._major_version, self._minor_version) <= (15, 8): + return {"response": { + "status": 1, + "message": "Fess 15.6 to 15.8 cannot receive uploads through the admin API (fixed in 15.9).", + }} + url = f"{self.base_url}/api/admin/storage/upload/" + # no Content-Type: httpx sets the multipart boundary + headers = {"Authorization": self.admin_api_headers["Authorization"]} + try: + with file_path.open("rb") as f: + response = httpx.post( + url, headers=headers, data={"path": directory.strip("/")}, + files={"file": (file_path.name, f)}, timeout=self.timeout + ) + except httpx.RequestError as e: + raise FessAPIClientError( + status_code=-1, + content=f"Network error: {str(e)}" + ) from e + return self._parse_json(response) + # SystemInfo / Stats APIs def get_systeminfo(self) -> dict: diff --git a/src/fessctl/cli.py b/src/fessctl/cli.py index 9fe1aab..65ea5ef 100644 --- a/src/fessctl/cli.py +++ b/src/fessctl/cli.py @@ -19,14 +19,14 @@ from fessctl.commands.failureurl import failureurl_app from fessctl.commands.fileauth import fileauth_app from fessctl.commands.fileconfig import fileconfig_app -# from fessctl.commands.general import general_app +from fessctl.commands.general import general_app from fessctl.commands.group import group_app 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.pathmap import pathmap_app -# from fessctl.commands.plugin import plugin_app +from fessctl.commands.plugin import plugin_app from fessctl.commands.role import role_app from fessctl.commands.relatedcontent import relatedcontent_app from fessctl.commands.relatedquery import relatedquery_app @@ -34,7 +34,7 @@ # from fessctl.commands.searchlist import searchlist_app from fessctl.commands.scheduler import scheduler_app from fessctl.commands.stats import stats_app -# from fessctl.commands.storage import storage_app +from fessctl.commands.storage import storage_app from fessctl.commands.suggest import suggest_app from fessctl.commands.tagtype import tagtype_app from fessctl.commands.theme import theme_app @@ -58,14 +58,14 @@ app.add_typer(failureurl_app, name="failureurl") app.add_typer(fileauth_app, name="fileauth") app.add_typer(fileconfig_app, name="fileconfig") -# app.add_typer(general_app, name="general") +app.add_typer(general_app, name="general") app.add_typer(group_app, name="group") 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(pathmap_app, name="pathmap") -# app.add_typer(plugin_app, name="plugin") +app.add_typer(plugin_app, name="plugin") app.add_typer(role_app, name="role") app.add_typer(relatedcontent_app, name="relatedcontent") app.add_typer(relatedquery_app, name="relatedquery") @@ -73,7 +73,7 @@ # app.add_typer(searchlist_app, name="searchlist") app.add_typer(scheduler_app, name="scheduler") app.add_typer(stats_app, name="stats") -# app.add_typer(storage_app, name="storage") +app.add_typer(storage_app, name="storage") app.add_typer(suggest_app, name="suggest") app.add_typer(tagtype_app, name="tagtype") app.add_typer(theme_app, name="theme") diff --git a/src/fessctl/commands/general.py b/src/fessctl/commands/general.py new file mode 100644 index 0000000..c12dd1a --- /dev/null +++ b/src/fessctl/commands/general.py @@ -0,0 +1,129 @@ +import json +from typing import List + +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 + +general_app = typer.Typer() + +# The update request must carry these (@Required in the Fess EditForm); every other setting that +# is left out keeps its stored value. +REQUIRED_KEYS = ( + "day_for_cleanup", + "crawling_thread_count", + "failure_count_threshold", + "csv_file_encoding", +) + +# Settings that the server leaves out of the GET response while they are unset. +UNSET_OMITTED_KEYS = ( + "ldap_provider_url", + "ldap_security_principal", + "ldap_admin_security_principal", + "ldap_admin_security_credentials", + "ldap_base_dn", + "ldap_account_filter", +) + + +@general_app.command("get") +def get_general( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Show the General system settings. + """ + client = FessAPIClient(Settings()) + result = client.get_general() + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + setting = result.get("response", {}).get("setting", {}) + typer.echo(format_detail_markdown( + "General Settings", setting, [(key, key) for key in setting])) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to get General settings. {message} Status code: {status}", "General", "get")) + + if status != 0: + raise typer.Exit(code=status) + + +@general_app.command("update") +def update_general( + settings: List[str] = typer.Option( + [], "--set", help="Setting to change as KEY=VALUE (repeatable). KEY is a name shown by `general get`" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Change General system settings; every setting not given keeps its value. + + Secrets are returned masked by `general get`. A new value replaces the stored secret, but one + made only of '*' or empty is ignored by the server, so a secret cannot be cleared here + (spnego_preauth_password is the exception: empty removes it). For any other setting an empty + value stores an empty string. + """ + changes = {} + for item in settings: + key, separator, value = item.partition("=") + if not separator or not key: + raise typer.BadParameter(f"'{item}' is not KEY=VALUE", param_hint="--set") + changes[key] = value + if not changes: + raise typer.BadParameter("give at least one KEY=VALUE", param_hint="--set") + + client = FessAPIClient(Settings()) + current = client.get_general() + status = current.get("response", {}).get("status", 1) + if status != 0: + message: str = current.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to get General settings. {message} Status code: {status}", "General", "update")) + raise typer.Exit(code=status) + setting = current.get("response", {}).get("setting", {}) + + known = set(setting) | set(UNSET_OMITTED_KEYS) + unknown = [key for key in changes if key not in known] + if unknown: + raise typer.BadParameter( + f"unknown setting(s) for this Fess version: {', '.join(unknown)} (see `general get`)", param_hint="--set") + + request = {key: setting[key] for key in REQUIRED_KEYS if key in setting} + for key, value in changes.items(): + if isinstance(setting.get(key), int): + try: + request[key] = int(value) + except ValueError: + raise typer.BadParameter(f"{key} takes an integer, got '{value}'", param_hint="--set") + else: + request[key] = value + + result = client.update_general(request) + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + typer.echo(format_result_markdown(True, f"General settings updated ({', '.join(changes)}).", "General", "update")) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to update General settings. {message} Status code: {status}", "General", "update")) + + if status != 0: + raise typer.Exit(code=status) diff --git a/src/fessctl/commands/plugin.py b/src/fessctl/commands/plugin.py new file mode 100644 index 0000000..8d7b0f4 --- /dev/null +++ b/src/fessctl/commands/plugin.py @@ -0,0 +1,135 @@ +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 + +plugin_app = typer.Typer() + +# Fess queries the remote plugin repositories while serving `available`, which +# can take longer than the default client timeout. +REMOTE_TIMEOUT = 60.0 + + +def _list_plugins(result: dict, title: str, action: str, output: str): + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + plugins = result.get("response", {}).get("plugins", []) + if not plugins: + typer.echo(f"No {title} found.") + else: + typer.echo(format_list_markdown(title, plugins, [ + ("NAME", "name"), + ("VERSION", "version"), + ("TYPE", "type"), + ])) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to list {title}. {message} Status code: {status}", "Plugin", action)) + + if status != 0: + raise typer.Exit(code=status) + + +@plugin_app.command("installed") +def installed_plugins( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + List the installed plugins. + """ + client = FessAPIClient(Settings()) + _list_plugins(client.get_installed_plugins(), "Installed Plugins", "installed", output) + + +@plugin_app.command("available") +def available_plugins( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + List the plugins that can be installed from the configured plugin repositories. + """ + client = FessAPIClient(Settings(), timeout=REMOTE_TIMEOUT) + _list_plugins(client.get_available_plugins(), "Available Plugins", "available", output) + + +@plugin_app.command("install") +def install_plugin( + name: str = typer.Argument(..., help="Plugin name (see `plugin available`)"), + version: str = typer.Argument(..., help="Plugin version"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Install a plugin by name and version. Fess downloads the jar in the background after the + request is accepted, so check `plugin installed`; restart Fess to activate the plugin. + """ + client = FessAPIClient(Settings()) + result = client.install_plugin(name, version) + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + typer.echo(format_result_markdown(True, f"Installation of plugin '{name}' {version} requested; Fess downloads it in the background (see `plugin installed`). Restart Fess to activate it.", "Plugin", "install", name)) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to install Plugin. {message} Status code: {status}", "Plugin", "install")) + + if status != 0: + raise typer.Exit(code=status) + + +@plugin_app.command("uninstall") +def uninstall_plugin( + name: str = typer.Argument(..., help="Plugin name (see `plugin installed`)"), + version: str = typer.Argument(..., help="Installed plugin version"), + 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)" + ), +): + """ + Uninstall a plugin by name and version. Fess deletes the jar in the background after the + request is accepted, and accepts it even when that plugin is not installed; check + `plugin installed` and restart Fess for the removal to take effect. + """ + if not yes: + typer.confirm(f"Uninstall plugin '{name}' {version}?", abort=True) + + client = FessAPIClient(Settings()) + result = client.uninstall_plugin(name, version) + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + typer.echo(format_result_markdown(True, f"Removal of plugin '{name}' {version} requested; Fess deletes it in the background (see `plugin installed`). Restart Fess for it to take effect.", "Plugin", "uninstall", name)) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to uninstall Plugin. {message} Status code: {status}", "Plugin", "uninstall")) + + if status != 0: + raise typer.Exit(code=status) diff --git a/src/fessctl/commands/storage.py b/src/fessctl/commands/storage.py new file mode 100644 index 0000000..2c2ef3d --- /dev/null +++ b/src/fessctl/commands/storage.py @@ -0,0 +1,178 @@ +import json +from pathlib import Path +from typing import Optional + +import click +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 + +storage_app = typer.Typer() + +# Uploads and downloads move file content, so they get longer than the default request timeout. +TRANSFER_TIMEOUT = 300.0 + + +def _object_path(item: dict) -> str: + """The path to give the other storage commands: the item's directory plus its name.""" + directory = item.get("path") + return f"{directory}/{item['name']}" if directory else item["name"] + + +def _echo_result(result: dict, output: str, success_message: str, failure_message: str, action: str, resource_id: str = ""): + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + typer.echo(format_result_markdown(True, success_message, "Storage", action, resource_id)) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"{failure_message} {message} Status code: {status}", "Storage", action)) + + if status != 0: + raise typer.Exit(code=status) + + +@storage_app.command("list") +def list_storage( + path: str = typer.Argument("", help="Directory to list, e.g. docs/2026 (default: the bucket root)"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + List the objects and directories under a path of the configured object storage. + """ + # Fess talks to the storage backend while serving this request; an unreachable + # backend can take far longer than the default timeout to answer. + client = FessAPIClient(Settings(), timeout=TRANSFER_TIMEOUT) + result = client.get_storage_items(path) + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + items = result.get("response", {}).get("items", []) + if not items: + typer.echo("No Storage items found.") + else: + rows = [{ + "path": _object_path(item), + "type": "directory" if item.get("directory") else "file", + "size": item.get("size"), + "lastModified": item.get("lastModified"), + } for item in items] + typer.echo(format_list_markdown("Storage Items", rows, [ + ("PATH", "path"), + ("TYPE", "type"), + ("SIZE", "size"), + ("LAST MODIFIED", "lastModified"), + ])) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to list Storage items. {message} Status code: {status}", "Storage", "list")) + + if status != 0: + raise typer.Exit(code=status) + + +@storage_app.command("download") +def download_storage( + path: str = typer.Argument(..., help="Object path, e.g. docs/report.pdf (see `storage list`)"), + file: Optional[Path] = typer.Option( + None, "--file", "-f", help="Write the object to this file (default: standard output)" + ), + force: bool = typer.Option( + False, "--force", help="Overwrite --file if it exists" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format for the result (text, json, yaml)" + ), +): + """ + Download an object to a file, or to standard output without --file. + """ + if file is not None and file.exists() and not force: + typer.echo(format_result_markdown(False, f"{file} already exists. Use --force to overwrite it.", "Storage", "download")) + raise typer.Exit(code=1) + + if file is not None and not file.parent.is_dir(): + typer.echo(format_result_markdown(False, f"The directory {file.parent} does not exist.", "Storage", "download")) + raise typer.Exit(code=1) + + client = FessAPIClient(Settings(), timeout=TRANSFER_TIMEOUT) + response = client.download_storage_file(path) + + if response.headers.get("content-type", "").startswith("application/json"): + # a failure that Fess could report before streaming + result = response.json() + status = result.get("response", {}).get("status", 1) + _echo_result(result, output, "", "Failed to download Storage object.", "download") + raise typer.Exit(code=status or 1) + if response.headers.get("content-length") == "0": + # Fess starts the response before it reads the object, so a missing object or an + # unreachable storage comes back as a 200 with an explicit empty body (a real empty + # object is sent chunked). + result = {"response": {"status": 1, "message": "The server returned an empty response; check that the object exists and the storage is reachable."}} + _echo_result(result, output, "", "Failed to download Storage object.", "download") + raise typer.Exit(code=1) + + if file is None: + click.get_binary_stream("stdout").write(response.content) + return + + file.write_bytes(response.content) + result = {"response": {"status": 0, "file": str(file), "size": len(response.content)}} + _echo_result(result, output, f"Downloaded '{path}' to {file} ({len(response.content)} bytes).", "", "download", path) + + +@storage_app.command("upload") +def upload_storage( + file: Path = typer.Argument(..., exists=True, dir_okay=False, readable=True, help="Local file to upload"), + directory: str = typer.Option( + "", "--dir", "-d", help="Directory in the storage to upload into, e.g. docs/2026 (default: the bucket root)" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Upload a file into a directory of the object storage under its own file name. + An existing object of that name is overwritten. Not possible on Fess 15.6 to 15.8 + (the server cannot receive it); works on 14.x and 15.9+. + """ + client = FessAPIClient(Settings(), timeout=TRANSFER_TIMEOUT) + result = client.upload_storage_file(file, directory) + target = f"{directory.strip('/')}/{file.name}" if directory.strip("/") else file.name + _echo_result(result, output, f"Uploaded {file} as '{target}'.", "Failed to upload Storage object.", "upload", target) + + +@storage_app.command("delete") +def delete_storage( + path: str = typer.Argument(..., help="Object path, e.g. docs/report.pdf (see `storage list`)"), + 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 an object from the object storage. + """ + if not yes: + typer.confirm(f"Delete storage object '{path}'?", abort=True) + + client = FessAPIClient(Settings()) + result = client.delete_storage_file(path) + _echo_result(result, output, f"Storage object '{path}' deleted successfully.", "Failed to delete Storage object.", "delete", path) diff --git a/tests/commands/test_general.py b/tests/commands/test_general.py new file mode 100644 index 0000000..d29e854 --- /dev/null +++ b/tests/commands/test_general.py @@ -0,0 +1,73 @@ +import json + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.general import general_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def _get_setting(runner): + result = runner.invoke(general_app, ["get", "--output", "json"]) + assert result.exit_code == 0, f"Get failed: {result.stdout}" + resp = json.loads(result.stdout) + assert resp.get("response", {}).get("status") == 0 + return resp["response"]["setting"] + + +def test_general_get(runner, fess_service): + setting = _get_setting(runner) + for key in ("day_for_cleanup", "crawling_thread_count", "failure_count_threshold", "csv_file_encoding"): + assert key in setting + + result = runner.invoke(general_app, ["get"]) + assert result.exit_code == 0 + assert "| csv_file_encoding |" in result.stdout + + +def test_general_update_changes_only_the_given_settings(runner, fess_service): + before = _get_setting(runner) + new_purge_days = 45 if before["purge_job_log_day"] != 45 else 46 + new_notice = "fessctl-test" if before["notification_login"] != "fessctl-test" else "fessctl-test2" + try: + result = runner.invoke(general_app, [ + "update", + "--set", f"purge_job_log_day={new_purge_days}", + "--set", f"notification_login={new_notice}", + ]) + assert result.exit_code == 0, f"Update failed: {result.stdout}" + + after = _get_setting(runner) + assert after["purge_job_log_day"] == new_purge_days + assert after["notification_login"] == new_notice + unchanged = {k: v for k, v in after.items() if k not in ("purge_job_log_day", "notification_login")} + assert unchanged == {k: v for k, v in before.items() if k in unchanged} + assert set(after) == set(before) + finally: + runner.invoke(general_app, [ + "update", + "--set", f"purge_job_log_day={before['purge_job_log_day']}", + "--set", f"notification_login={before['notification_login']}", + ]) + + assert _get_setting(runner) == before + + +def test_general_update_rejects_unknown_setting(runner, fess_service): + result = runner.invoke(general_app, ["update", "--set", "no_such_setting=1"]) + assert result.exit_code == 2 + + +def test_general_update_validation_error(runner, fess_service): + before = _get_setting(runner) + result = runner.invoke(general_app, ["update", "--set", "day_for_cleanup=99999"]) + assert result.exit_code != 0 + assert "failed to update general settings" in result.stdout.lower() + assert _get_setting(runner) == before diff --git a/tests/commands/test_plugin.py b/tests/commands/test_plugin.py new file mode 100644 index 0000000..c63570b --- /dev/null +++ b/tests/commands/test_plugin.py @@ -0,0 +1,81 @@ +import json +import time + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.plugin import plugin_app + +# a small data store plugin, published for every Fess line +PLUGIN_NAME = "fess-ds-csv" + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def _plugins(runner, sub): + result = runner.invoke(plugin_app, [sub, "--output", "json"]) + assert result.exit_code == 0, f"{sub} failed: {result.stdout}" + resp = json.loads(result.stdout) + assert resp.get("response", {}).get("status") == 0 + plugins = resp["response"].get("plugins", []) + assert isinstance(plugins, list) + return plugins + + +def _wait_until(runner, installed, name, timeout=60): + """Install and uninstall are accepted at once and carried out in a background thread.""" + deadline = time.time() + timeout + while time.time() < deadline: + present = any(p["name"] == name for p in _plugins(runner, "installed")) + if present == installed: + return True + time.sleep(1) + return False + + +def test_plugin_installed(runner, fess_service): + plugins = _plugins(runner, "installed") + assert all("name" in p and "version" in p for p in plugins) + + result = runner.invoke(plugin_app, ["installed"]) + assert result.exit_code == 0 + + +def test_plugin_available(runner, fess_service): + plugins = _plugins(runner, "available") + assert all("name" in p and "version" in p for p in plugins) + + +def test_plugin_install_unknown_plugin(runner, fess_service): + result = runner.invoke(plugin_app, ["install", "no-such-plugin", "0.0.1"]) + assert result.exit_code != 0 + assert "failed to install plugin" in result.stdout.lower() + + +def test_plugin_uninstall_aborts_without_confirmation(runner, fess_service): + result = runner.invoke(plugin_app, ["uninstall", PLUGIN_NAME, "0.0.1"], input="n\n") + assert result.exit_code != 0 + + +def test_plugin_install_and_uninstall(runner, fess_service): + if any(p["name"] == PLUGIN_NAME for p in _plugins(runner, "installed")): + pytest.skip(f"{PLUGIN_NAME} is already installed in the Fess under test") + candidates = [p for p in _plugins(runner, "available") if p["name"] == PLUGIN_NAME] + if not candidates: + pytest.skip(f"the Fess under test lists no {PLUGIN_NAME} (plugin repository unreachable?)") + version = candidates[0]["version"] + + try: + result = runner.invoke(plugin_app, ["install", PLUGIN_NAME, version]) + assert result.exit_code == 0, f"Install failed: {result.stdout}" + assert _wait_until(runner, True, PLUGIN_NAME), f"{PLUGIN_NAME} was not installed in time" + finally: + result = runner.invoke(plugin_app, ["uninstall", PLUGIN_NAME, version, "--yes"]) + assert result.exit_code == 0, f"Uninstall failed: {result.stdout}" + assert _wait_until(runner, False, PLUGIN_NAME), f"{PLUGIN_NAME} was not uninstalled in time" diff --git a/tests/commands/test_storage.py b/tests/commands/test_storage.py new file mode 100644 index 0000000..5d2b3ab --- /dev/null +++ b/tests/commands/test_storage.py @@ -0,0 +1,72 @@ +import json + +import pytest +from typer.testing import CliRunner + +from conftest import fess_version_at_least +from fessctl.commands.storage import storage_app + +# The Fess under test has no object storage configured, so these tests cover how the +# commands behave without a backend. Reading and writing objects is covered by the unit tests +# and was verified by hand against an S3-compatible server. + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def test_storage_list_without_backend(runner, fess_service): + result = runner.invoke(storage_app, ["list"]) + if fess_version_at_least(15, 9): + # 15.9 reports a storage it cannot reach; the message names the plugin to install + assert result.exit_code != 0 + assert "storage access error" in result.stdout.lower() + else: + # earlier versions answer an unreachable storage with an empty list + assert result.exit_code == 0 + assert "No Storage items found." in result.stdout + + +def test_storage_list_json_without_backend(runner, fess_service): + result = runner.invoke(storage_app, ["list", "docs/sub", "--output", "json"]) + resp = json.loads(result.stdout) + if fess_version_at_least(15, 9): + assert result.exit_code != 0 + assert resp["response"]["status"] != 0 + else: + assert result.exit_code == 0 + assert resp["response"]["items"] == [] + + +def test_storage_delete_aborts_without_confirmation(runner, fess_service): + result = runner.invoke(storage_app, ["delete", "no/such/object.txt"], input="n\n") + assert result.exit_code != 0 + + +def test_storage_download_refuses_to_overwrite(runner, fess_service, tmp_path): + target = tmp_path / "existing.txt" + target.write_text("keep") + result = runner.invoke(storage_app, ["download", "no/such/object.txt", "--file", str(target)]) + assert result.exit_code != 0 + assert target.read_text() == "keep" + + +def test_storage_download_missing_object_fails(runner, fess_service, tmp_path): + target = tmp_path / "out.txt" + result = runner.invoke(storage_app, ["download", "no/such/object.txt", "--file", str(target)]) + assert result.exit_code != 0 + assert "failed to download storage object" in result.stdout.lower() + assert not target.exists() + + +def test_storage_upload_without_backend_fails(runner, fess_service, tmp_path): + source = tmp_path / "fessctl-upload-test.txt" + source.write_text("fessctl") + result = runner.invoke(storage_app, ["upload", str(source), "--dir", "fessctl-test"]) + # 15.6 to 15.8 cannot receive uploads at all; the others fail on the missing backend + assert result.exit_code != 0 + assert "failed to upload storage object" in result.stdout.lower() diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index a79b1a5..b721f6f 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -636,3 +636,209 @@ def test_stop_scheduler(self, mock_put, client): call_url = mock_put.call_args[0][0] assert "scheduler-123" in call_url assert "/stop" in call_url + + +def _ok_response(**extra): + response = Mock() + response.json.return_value = {"response": {"status": 0}} + response.status_code = 200 + response.headers = {} + for key, value in extra.items(): + setattr(response, key, value) + return response + + +class TestDeleteWithBody: + """The plugin endpoint binds the name and version of a DELETE from a JSON body.""" + + @patch("httpx.request") + @patch("httpx.delete") + def test_delete_with_json_data_sends_the_body(self, mock_delete, mock_request, client): + mock_request.return_value = _ok_response() + + client.send_request(Action.DELETE, "http://test/api", json_data={"name": "x"}) + + mock_delete.assert_not_called() + assert mock_request.call_args[0] == ("DELETE", "http://test/api") + assert mock_request.call_args[1]["json"] == {"name": "x"} + + @patch("httpx.request") + @patch("httpx.delete") + def test_delete_without_json_data_is_unchanged(self, mock_delete, mock_request, client): + mock_delete.return_value = _ok_response() + + client.send_request(Action.DELETE, "http://test/api") + + mock_request.assert_not_called() + mock_delete.assert_called_once() + + +class TestGeneralAPIs: + @patch("httpx.get") + def test_get_general(self, mock_get, client): + mock_get.return_value = _ok_response() + + client.get_general() + + assert mock_get.call_args[0][0] == "http://localhost:8080/api/admin/general" + + @patch("httpx.put") + def test_update_general_uses_put(self, mock_put, client): + mock_put.return_value = _ok_response() + + client.update_general({"app_value": "x"}) + + assert mock_put.call_args[0][0] == "http://localhost:8080/api/admin/general" + assert mock_put.call_args[1]["json"] == {"app_value": "x"} + + @patch("httpx.post") + def test_update_general_uses_post_on_fess14(self, mock_post, client_v14): + mock_post.return_value = _ok_response() + + client_v14.update_general({"app_value": "x"}) + + assert mock_post.call_args[0][0] == "http://localhost:8080/api/admin/general" + + +class TestPluginAPIs: + @patch("httpx.get") + def test_installed_and_available_urls(self, mock_get, client): + mock_get.return_value = _ok_response() + + client.get_installed_plugins() + client.get_available_plugins() + + assert [c[0][0] for c in mock_get.call_args_list] == [ + "http://localhost:8080/api/admin/plugin/installed", + "http://localhost:8080/api/admin/plugin/available", + ] + + @patch("httpx.post") + def test_install_plugin(self, mock_post, client): + mock_post.return_value = _ok_response() + + client.install_plugin("fess-ds-csv", "15.9.0") + + assert mock_post.call_args[0][0] == "http://localhost:8080/api/admin/plugin" + assert mock_post.call_args[1]["json"] == {"name": "fess-ds-csv", "version": "15.9.0"} + + @patch("httpx.put") + def test_install_plugin_uses_put_on_fess14(self, mock_put, client_v14): + mock_put.return_value = _ok_response() + + client_v14.install_plugin("fess-ds-csv", "14.19.2") + + mock_put.assert_called_once() + + @patch("httpx.request") + def test_uninstall_plugin_sends_delete_with_body(self, mock_request, client): + mock_request.return_value = _ok_response() + + client.uninstall_plugin("fess-ds-csv", "15.9.0") + + assert mock_request.call_args[0] == ("DELETE", "http://localhost:8080/api/admin/plugin") + assert mock_request.call_args[1]["json"] == {"name": "fess-ds-csv", "version": "15.9.0"} + + +class TestStorageAPIs: + """Storage objects are addressed by the URL-safe Base64 of their path.""" + + @patch("httpx.get") + def test_list_root_has_no_id(self, mock_get, client): + mock_get.return_value = _ok_response() + + client.get_storage_items() + + assert mock_get.call_args[0][0] == "http://localhost:8080/api/admin/storage/list" + + @patch("httpx.get") + def test_list_encodes_the_path(self, mock_get, client): + mock_get.return_value = _ok_response() + + client.get_storage_items("docs/sub") + + assert mock_get.call_args[0][0] == "http://localhost:8080/api/admin/storage/list/ZG9jcy9zdWI=" + + @patch("httpx.get") + def test_path_with_characters_unsafe_in_urls_uses_the_urlsafe_alphabet(self, mock_get, client): + mock_get.return_value = _ok_response() + + client.get_storage_items("a?b>c") # standard Base64 is "YT9iPmM=", with no '+' or '/' + client.get_storage_items("???>>>") # standard Base64 "Pz8/Pj4+" has both + + assert mock_get.call_args_list[1][0][0].endswith("/Pz8_Pj4-") + + @patch("httpx.get") + def test_download_returns_the_raw_response(self, mock_get, client): + mock_get.return_value = _ok_response() + + response = client.download_storage_file("docs/a.txt") + + assert response is mock_get.return_value + assert mock_get.call_args[0][0] == "http://localhost:8080/api/admin/storage/download/ZG9jcy9hLnR4dA==/" + + @patch("httpx.get") + def test_download_network_error(self, mock_get, client): + mock_get.side_effect = httpx.RequestError("Connection refused") + + with pytest.raises(FessAPIClientError) as exc_info: + client.download_storage_file("a.txt") + + assert exc_info.value.status_code == -1 + + @patch("httpx.delete") + def test_delete_storage_file(self, mock_delete, client): + mock_delete.return_value = _ok_response() + + client.delete_storage_file("docs/a.txt") + + assert mock_delete.call_args[0][0] == "http://localhost:8080/api/admin/storage/delete/ZG9jcy9hLnR4dA==/" + + @pytest.mark.parametrize("version", ["14.19.2", "15.9.0"]) + @patch("httpx.post") + def test_upload_posts_multipart(self, mock_post, version, mock_settings, tmp_path): + mock_settings.fess_version = version + mock_post.return_value = _ok_response() + source = tmp_path / "report.pdf" + source.write_bytes(b"pdf") + + result = FessAPIClient(mock_settings).upload_storage_file(source, "docs") + + assert result == {"response": {"status": 0}} + assert mock_post.call_args[0][0] == "http://localhost:8080/api/admin/storage/upload/" + kwargs = mock_post.call_args[1] + assert kwargs["data"] == {"path": "docs"} + assert kwargs["files"]["file"][0] == "report.pdf" + # the multipart Content-Type (with its boundary) must be left to httpx + assert kwargs["headers"] == {"Authorization": "Bearer test-token"} + + @pytest.mark.parametrize("directory,expected", [("/a/b/c/", "a/b/c"), ("/x", "x"), ("", "")]) + @patch("httpx.post") + def test_upload_strips_slashes_around_the_directory(self, mock_post, directory, expected, mock_settings, tmp_path): + """Fess 15.9 joins the directory and the name with a slash without normalising, so + a leading or trailing slash would store an object that cannot be read back.""" + mock_settings.fess_version = "15.9.0" + mock_post.return_value = _ok_response() + source = tmp_path / "report.pdf" + source.write_bytes(b"pdf") + + FessAPIClient(mock_settings).upload_storage_file(source, directory) + + assert mock_post.call_args[1]["data"] == {"path": expected} + + @pytest.mark.parametrize("version", ["15.6.1", "15.7.0", "15.8.0"]) + @patch("httpx.request") + @patch("httpx.post") + @patch("httpx.put") + def test_upload_is_refused_where_fess_cannot_receive_it(self, mock_put, mock_post, mock_request, version, mock_settings, tmp_path): + mock_settings.fess_version = version + source = tmp_path / "report.pdf" + source.write_bytes(b"pdf") + + result = FessAPIClient(mock_settings).upload_storage_file(source, "docs") + + assert result["response"]["status"] == 1 + assert "15.9" in result["response"]["message"] + mock_put.assert_not_called() + mock_post.assert_not_called() + mock_request.assert_not_called() diff --git a/tests/unit/test_general.py b/tests/unit/test_general.py new file mode 100644 index 0000000..bb2c854 --- /dev/null +++ b/tests/unit/test_general.py @@ -0,0 +1,181 @@ +""" +Unit tests for the general command. +""" +import json +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.general import general_app + +SETTING = { + "day_for_cleanup": 3, + "crawling_thread_count": 5, + "failure_count_threshold": -1, + "csv_file_encoding": "UTF-8", + "purge_job_log_day": -1, + "notification_login": "", + "storage_secret_key": "**********", +} + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _client(mock_client_class, update_status=0): + mock_client = Mock() + mock_client.get_general.return_value = {"response": {"status": 0, "setting": dict(SETTING)}} + mock_client.update_general.return_value = {"response": {"status": update_status}} + mock_client_class.return_value = mock_client + return mock_client + + +class TestGeneralGet: + @patch("fessctl.commands.general.FessAPIClient") + def test_get_text_output(self, mock_client_class, runner): + _client(mock_client_class) + + result = runner.invoke(general_app, ["get"]) + + assert result.exit_code == 0 + assert "| day_for_cleanup | 3 |" in result.stdout + assert "| storage_secret_key | ********** |" in result.stdout + + @patch("fessctl.commands.general.FessAPIClient") + def test_get_json_output(self, mock_client_class, runner): + _client(mock_client_class) + + result = runner.invoke(general_app, ["get", "--output", "json"]) + + assert json.loads(result.stdout)["response"]["setting"]["csv_file_encoding"] == "UTF-8" + + @patch("fessctl.commands.general.FessAPIClient") + def test_get_failure_exits_with_status(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_general.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(general_app, ["get"]) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + +class TestGeneralUpdate: + @patch("fessctl.commands.general.FessAPIClient") + def test_update_sends_required_keys_and_only_the_changes(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + result = runner.invoke(general_app, ["update", "--set", "notification_login=hello", "--set", "purge_job_log_day=45"]) + + assert result.exit_code == 0 + assert "General settings updated (notification_login, purge_job_log_day)." in result.stdout + mock_client.update_general.assert_called_once_with({ + "day_for_cleanup": 3, + "crawling_thread_count": 5, + "failure_count_threshold": -1, + "csv_file_encoding": "UTF-8", + "notification_login": "hello", + "purge_job_log_day": 45, + }) + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_never_echoes_masked_secrets(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + runner.invoke(general_app, ["update", "--set", "notification_login=x"]) + + assert "storage_secret_key" not in mock_client.update_general.call_args[0][0] + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_value_may_contain_equals_and_be_empty(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + runner.invoke(general_app, ["update", "--set", "csv_file_encoding=a=b", "--set", "notification_login="]) + + sent = mock_client.update_general.call_args[0][0] + assert sent["csv_file_encoding"] == "a=b" + assert sent["notification_login"] == "" + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_accepts_settings_the_server_omits_while_unset(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + result = runner.invoke(general_app, ["update", "--set", "ldap_provider_url=ldap://localhost:389"]) + + assert result.exit_code == 0 + assert mock_client.update_general.call_args[0][0]["ldap_provider_url"] == "ldap://localhost:389" + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_rejects_unknown_setting(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + result = runner.invoke(general_app, ["update", "--set", "no_such_setting=1"]) + + assert result.exit_code == 2 + mock_client.update_general.assert_not_called() + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_rejects_non_integer_for_integer_setting(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + result = runner.invoke(general_app, ["update", "--set", "day_for_cleanup=abc"]) + + assert result.exit_code == 2 + mock_client.update_general.assert_not_called() + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_requires_a_change(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + assert runner.invoke(general_app, ["update"]).exit_code == 2 + assert runner.invoke(general_app, ["update", "--set", "notification_login"]).exit_code == 2 + mock_client.update_general.assert_not_called() + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_failure_exits_with_status(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + mock_client.update_general.return_value = {"response": {"status": 1, "message": "must be less than or equal to 1000."}} + + result = runner.invoke(general_app, ["update", "--set", "day_for_cleanup=99999"]) + + assert result.exit_code == 1 + assert "must be less than or equal to 1000." in result.stdout + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_stops_when_get_fails(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_general.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(general_app, ["update", "--set", "notification_login=x"]) + + assert result.exit_code == 3 + mock_client.update_general.assert_not_called() + + +class TestExitStatusInEveryFormat: + @pytest.mark.parametrize("output", ["json", "yaml"]) + @patch("fessctl.commands.general.FessAPIClient") + def test_get_failure_exits_with_status(self, mock_client_class, runner, output): + mock_client = Mock() + mock_client.get_general.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(general_app, ["get", "--output", output]) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + @pytest.mark.parametrize("output", ["json", "yaml"]) + @patch("fessctl.commands.general.FessAPIClient") + def test_update_failure_exits_with_status(self, mock_client_class, runner, output): + mock_client = _client(mock_client_class) + mock_client.update_general.return_value = {"response": {"status": 1, "message": "bad value"}} + + result = runner.invoke(general_app, ["update", "--set", "notification_login=x", "--output", output]) + + assert result.exit_code == 1 diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py new file mode 100644 index 0000000..42911f3 --- /dev/null +++ b/tests/unit/test_plugin.py @@ -0,0 +1,151 @@ +""" +Unit tests for the plugin command. +""" +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.plugin import plugin_app + +PLUGINS = {"response": {"status": 0, "plugins": [ + {"name": "fess-ds-csv", "id": "fess-ds-csv:15.9.0", "type": "fess-ds", "version": "15.9.0"}, +]}} + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestPluginList: + @patch("fessctl.commands.plugin.FessAPIClient") + def test_installed_text_output(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_installed_plugins.return_value = PLUGINS + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["installed"]) + + assert result.exit_code == 0 + assert "| fess-ds-csv | 15.9.0 | fess-ds |" in result.stdout + + @patch("fessctl.commands.plugin.FessAPIClient") + def test_available_empty(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_available_plugins.return_value = {"response": {"status": 0, "plugins": []}} + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["available"]) + + assert result.exit_code == 0 + assert "No Available Plugins found." in result.stdout + + @patch("fessctl.commands.plugin.FessAPIClient") + def test_available_json_output(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_available_plugins.return_value = PLUGINS + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["available", "--output", "json"]) + + assert '"fess-ds-csv"' in result.stdout + + @patch("fessctl.commands.plugin.FessAPIClient") + def test_installed_failure_exits_with_status(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_installed_plugins.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["installed"]) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + +class TestPluginInstall: + @patch("fessctl.commands.plugin.FessAPIClient") + def test_install(self, mock_client_class, runner): + mock_client = Mock() + mock_client.install_plugin.return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["install", "fess-ds-csv", "15.9.0"]) + + assert result.exit_code == 0 + assert "requested" in result.stdout + mock_client.install_plugin.assert_called_once_with("fess-ds-csv", "15.9.0") + + @patch("fessctl.commands.plugin.FessAPIClient") + def test_install_failure_exits_with_status(self, mock_client_class, runner): + mock_client = Mock() + mock_client.install_plugin.return_value = {"response": {"status": 1, "message": "invalid name or version"}} + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["install", "nope", "0.0.1"]) + + assert result.exit_code == 1 + assert "invalid name or version" in result.stdout + + +class TestPluginUninstall: + @patch("fessctl.commands.plugin.FessAPIClient") + def test_uninstall_aborts_without_confirmation(self, mock_client_class, runner): + result = runner.invoke(plugin_app, ["uninstall", "fess-ds-csv", "15.9.0"], input="n\n") + + assert result.exit_code != 0 + mock_client_class.return_value.uninstall_plugin.assert_not_called() + + @patch("fessctl.commands.plugin.FessAPIClient") + def test_uninstall_confirmed(self, mock_client_class, runner): + mock_client = Mock() + mock_client.uninstall_plugin.return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["uninstall", "fess-ds-csv", "15.9.0"], input="y\n") + + assert result.exit_code == 0 + mock_client.uninstall_plugin.assert_called_once_with("fess-ds-csv", "15.9.0") + + @patch("fessctl.commands.plugin.FessAPIClient") + def test_uninstall_with_yes_skips_prompt(self, mock_client_class, runner): + mock_client = Mock() + mock_client.uninstall_plugin.return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["uninstall", "fess-ds-csv", "15.9.0", "--yes"]) + + assert result.exit_code == 0 + assert "requested" in result.stdout + + +@pytest.mark.parametrize("output", ["json", "yaml"]) +@patch("fessctl.commands.plugin.FessAPIClient") +def test_failure_exits_with_status_in_every_format(mock_client_class, output): + mock_client = Mock() + failure = {"response": {"status": 1, "message": "invalid name or version"}} + mock_client.install_plugin.return_value = failure + mock_client.get_available_plugins.return_value = failure + mock_client_class.return_value = mock_client + runner = CliRunner() + + assert runner.invoke(plugin_app, ["install", "x", "1", "--output", output]).exit_code == 1 + assert runner.invoke(plugin_app, ["available", "--output", output]).exit_code == 1 + + +def test_available_uses_a_long_timeout(): + """`available` makes Fess query the remote plugin repositories.""" + from unittest.mock import Mock, patch + + from typer.testing import CliRunner + + from fessctl.commands.plugin import plugin_app + + with patch("fessctl.commands.plugin.FessAPIClient") as mock_client_class: + mock_client = Mock() + mock_client.get_available_plugins.return_value = {"response": {"status": 0, "plugins": []}} + mock_client_class.return_value = mock_client + + CliRunner().invoke(plugin_app, ["available"]) + + assert mock_client_class.call_args.kwargs["timeout"] >= 60 diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py new file mode 100644 index 0000000..cb96465 --- /dev/null +++ b/tests/unit/test_storage.py @@ -0,0 +1,284 @@ +""" +Unit tests for the storage command. +""" +import json +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.storage import storage_app + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _download_response(body=b"hello", content_type="application/octet-stream", content_length=None, json_body=None): + response = Mock() + response.content = body + response.headers = {"content-type": content_type} + if content_length is not None: + response.headers["content-length"] = content_length + response.json.return_value = json_body + return response + + +class TestStorageList: + @patch("fessctl.commands.storage.FessAPIClient") + def test_list_shows_full_object_paths(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_storage_items.return_value = {"response": {"status": 0, "items": [ + {"path": "docs", "name": "sub", "directory": True, "size": 0}, + {"path": "docs", "name": "a.txt", "directory": False, "size": 6, "lastModified": "2026-10-16T12:28:45Z"}, + {"name": "top.txt", "directory": False, "size": 6, "lastModified": "2026-10-16T12:28:46Z"}, + ]}} + mock_client_class.return_value = mock_client + + result = runner.invoke(storage_app, ["list", "docs"]) + + assert result.exit_code == 0 + assert "| docs/sub | directory | 0 | - |" in result.stdout + assert "| docs/a.txt | file | 6 | 2026-10-16T12:28:45Z |" in result.stdout + assert "| top.txt | file | 6 | 2026-10-16T12:28:46Z |" in result.stdout + mock_client.get_storage_items.assert_called_once_with("docs") + + @patch("fessctl.commands.storage.FessAPIClient") + def test_list_defaults_to_the_root(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_storage_items.return_value = {"response": {"status": 0, "items": []}} + mock_client_class.return_value = mock_client + + result = runner.invoke(storage_app, ["list"]) + + assert "No Storage items found." in result.stdout + mock_client.get_storage_items.assert_called_once_with("") + + @patch("fessctl.commands.storage.FessAPIClient") + def test_list_failure_exits_with_status(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_storage_items.return_value = {"response": {"status": 1, "message": "Storage Access Error: No storage client is registered"}} + mock_client_class.return_value = mock_client + + result = runner.invoke(storage_app, ["list"]) + + assert result.exit_code == 1 + assert "No storage client is registered" in result.stdout + + +class TestStorageListTimeout: + @patch("fessctl.commands.storage.FessAPIClient") + def test_list_waits_for_a_slow_backend(self, mock_client_class, runner): + mock_client_class.return_value.get_storage_items.return_value = {"response": {"status": 0, "items": []}} + + runner.invoke(storage_app, ["list"]) + + assert mock_client_class.call_args.kwargs["timeout"] >= 60 + + +class TestStorageDownload: + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_to_file(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response(b"\x00\x01binary") + mock_client_class.return_value = mock_client + target = tmp_path / "out.bin" + + result = runner.invoke(storage_app, ["download", "docs/a.bin", "--file", str(target)]) + + assert result.exit_code == 0 + assert target.read_bytes() == b"\x00\x01binary" + mock_client.download_storage_file.assert_called_once_with("docs/a.bin") + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_refuses_a_missing_parent_directory(self, mock_client_class, runner, tmp_path): + target = tmp_path / "missing" / "a.txt" + + result = runner.invoke(storage_app, ["download", "docs/a.txt", "--file", str(target)]) + + assert result.exit_code == 1 + assert "does not exist" in result.stdout + mock_client_class.return_value.download_storage_file.assert_not_called() + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_to_stdout(self, mock_client_class, runner): + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response(b"hello\n") + mock_client_class.return_value = mock_client + + result = runner.invoke(storage_app, ["download", "top.txt"]) + + assert result.exit_code == 0 + assert result.stdout_bytes == b"hello\n" + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_refuses_to_overwrite(self, mock_client_class, runner, tmp_path): + target = tmp_path / "out.txt" + target.write_text("keep") + + result = runner.invoke(storage_app, ["download", "top.txt", "--file", str(target)]) + + assert result.exit_code == 1 + assert target.read_text() == "keep" + mock_client_class.return_value.download_storage_file.assert_not_called() + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_force_overwrites(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response(b"new") + mock_client_class.return_value = mock_client + target = tmp_path / "out.txt" + target.write_text("old") + + result = runner.invoke(storage_app, ["download", "top.txt", "--file", str(target), "--force"]) + + assert result.exit_code == 0 + assert target.read_text() == "new" + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_json_error_body_fails(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response( + content_type="application/json;charset=UTF-8", + json_body={"response": {"status": 1, "message": "The file was not found."}}) + mock_client_class.return_value = mock_client + target = tmp_path / "out.txt" + + result = runner.invoke(storage_app, ["download", "top.txt", "--file", str(target)]) + + assert result.exit_code == 1 + assert "The file was not found." in result.stdout + assert not target.exists() + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_explicit_empty_body_is_a_failure(self, mock_client_class, runner, tmp_path): + # Fess answers 200 with Content-Length: 0 when it cannot read the object + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response(b"", content_length="0") + mock_client_class.return_value = mock_client + target = tmp_path / "out.txt" + + result = runner.invoke(storage_app, ["download", "missing.txt", "--file", str(target)]) + + assert result.exit_code == 1 + assert "empty response" in result.stdout + assert not target.exists() + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_empty_object_sent_chunked_is_written(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response(b"") + mock_client_class.return_value = mock_client + target = tmp_path / "empty.txt" + + result = runner.invoke(storage_app, ["download", "empty.txt", "--file", str(target)]) + + assert result.exit_code == 0 + assert target.read_bytes() == b"" + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_json_output(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response(b"abc") + mock_client_class.return_value = mock_client + target = tmp_path / "out.txt" + + result = runner.invoke(storage_app, ["download", "top.txt", "--file", str(target), "--output", "json"]) + + assert json.loads(result.stdout) == {"response": {"status": 0, "file": str(target), "size": 3}} + + +class TestStorageUpload: + @patch("fessctl.commands.storage.FessAPIClient") + def test_upload(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.upload_storage_file.return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + source = tmp_path / "report.pdf" + source.write_bytes(b"pdf") + + result = runner.invoke(storage_app, ["upload", str(source), "--dir", "docs/2026"]) + + assert result.exit_code == 0 + assert "'docs/2026/report.pdf'" in result.stdout + mock_client.upload_storage_file.assert_called_once_with(source, "docs/2026") + + @patch("fessctl.commands.storage.FessAPIClient") + def test_upload_to_root(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.upload_storage_file.return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + source = tmp_path / "report.pdf" + source.write_bytes(b"pdf") + + result = runner.invoke(storage_app, ["upload", str(source)]) + + assert "'report.pdf'" in result.stdout + mock_client.upload_storage_file.assert_called_once_with(source, "") + + @patch("fessctl.commands.storage.FessAPIClient") + def test_upload_failure_exits_with_status(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.upload_storage_file.return_value = {"response": {"status": 1, "message": "Failed to upload report.pdf."}} + mock_client_class.return_value = mock_client + source = tmp_path / "report.pdf" + source.write_bytes(b"pdf") + + result = runner.invoke(storage_app, ["upload", str(source)]) + + assert result.exit_code == 1 + assert "Failed to upload report.pdf." in result.stdout + + @patch("fessctl.commands.storage.FessAPIClient") + def test_upload_missing_file(self, mock_client_class, runner, tmp_path): + result = runner.invoke(storage_app, ["upload", str(tmp_path / "missing.bin")]) + + assert result.exit_code == 2 + mock_client_class.return_value.upload_storage_file.assert_not_called() + + +class TestStorageDelete: + @patch("fessctl.commands.storage.FessAPIClient") + def test_delete_aborts_without_confirmation(self, mock_client_class, runner): + result = runner.invoke(storage_app, ["delete", "docs/a.txt"], input="n\n") + + assert result.exit_code != 0 + mock_client_class.return_value.delete_storage_file.assert_not_called() + + @patch("fessctl.commands.storage.FessAPIClient") + def test_delete_with_yes(self, mock_client_class, runner): + mock_client = Mock() + mock_client.delete_storage_file.return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + + result = runner.invoke(storage_app, ["delete", "docs/a.txt", "--yes"]) + + assert result.exit_code == 0 + assert "deleted successfully" in result.stdout + mock_client.delete_storage_file.assert_called_once_with("docs/a.txt") + + @patch("fessctl.commands.storage.FessAPIClient") + def test_delete_failure_exits_with_status(self, mock_client_class, runner): + mock_client = Mock() + mock_client.delete_storage_file.return_value = {"response": {"status": 1, "message": "Failed to delete the file a.txt."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(storage_app, ["delete", "a.txt", "--yes"]) + + assert result.exit_code == 1 + assert "Failed to delete the file a.txt." in result.stdout + + +@pytest.mark.parametrize("output", ["json", "yaml"]) +@patch("fessctl.commands.storage.FessAPIClient") +def test_failure_exits_with_status_in_every_format(mock_client_class, output, tmp_path): + mock_client = Mock() + failure = {"response": {"status": 1, "message": "Storage Access Error"}} + mock_client.get_storage_items.return_value = failure + mock_client.delete_storage_file.return_value = failure + mock_client_class.return_value = mock_client + runner = CliRunner() + + assert runner.invoke(storage_app, ["list", "--output", output]).exit_code == 1 + assert runner.invoke(storage_app, ["delete", "a.txt", "--yes", "--output", output]).exit_code == 1