From 1964c49ee9d8ae0a72796c748b76b51c6bfb4490 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Fri, 16 Oct 2026 21:42:14 +0900 Subject: [PATCH] feat: add the searchlist and documents commands searchlist wraps the admin document browser API (/api/admin/searchlist): search, get, create, update, delete and delete-by-query (confirms unless --yes). update reads the stored document first and sends back the url, title, role and boost the server requires on every update. documents bulk wraps /api/admin/documents/bulk and reads a JSON array, {"documents": [...]} or NDJSON from a file or stdin. A partial failure prints every item and exits with the server status. send_request now passes a JSON body on DELETE (delete-by-query binds its query from the body). Both APIs exist in Fess 14.19.2 through 15.9.0. --- skills/fessctl/SKILL.md | 4 +- .../fessctl/references/features/documents.md | 53 +++ .../fessctl/references/features/searchlist.md | 68 ++++ src/fessctl/api/client.py | 86 ++++- src/fessctl/cli.py | 8 +- src/fessctl/commands/documents.py | 74 ++++ src/fessctl/commands/searchlist.py | 302 ++++++++++++++++ tests/commands/test_documents.py | 99 +++++ tests/commands/test_searchlist.py | 179 ++++++++++ tests/unit/test_documents.py | 109 ++++++ tests/unit/test_searchlist.py | 337 ++++++++++++++++++ 11 files changed, 1311 insertions(+), 8 deletions(-) create mode 100644 skills/fessctl/references/features/documents.md create mode 100644 skills/fessctl/references/features/searchlist.md create mode 100644 src/fessctl/commands/documents.py create mode 100644 src/fessctl/commands/searchlist.py create mode 100644 tests/commands/test_documents.py create mode 100644 tests/commands/test_searchlist.py create mode 100644 tests/unit/test_documents.py create mode 100644 tests/unit/test_searchlist.py diff --git a/skills/fessctl/SKILL.md b/skills/fessctl/SKILL.md index a24fc0b..328e063 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 27 resource types and cross-feature workflows like initial crawl setup. license: Apache-2.0 version: 0.1.0 --- @@ -61,6 +61,8 @@ 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 | +| Indexed documents | references/features/searchlist.md | +| Bulk document indexing | references/features/documents.md | | Key match | references/features/keymatch.md | | Boost document | references/features/boostdoc.md | | Elevate word | references/features/elevateword.md | diff --git a/skills/fessctl/references/features/documents.md b/skills/fessctl/references/features/documents.md new file mode 100644 index 0000000..277c12a --- /dev/null +++ b/skills/fessctl/references/features/documents.md @@ -0,0 +1,53 @@ +# Bulk document indexing (fessctl `documents`) + +## What it is +Index many documents in one request, straight into the search index, without a crawl. This is the admin `documents/bulk` API. It only indexes; to change or delete documents afterwards use `fessctl searchlist`. + +Available in every Fess version fessctl supports (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0); the request body is identical in all of them. + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `bulk` | Index the documents in a file, or on stdin when the argument is `-`. | `` | + +The input is read as one of: +- a JSON array of document objects, +- an object `{"documents": [...]}`, +- NDJSON: one document object per line (blank lines are ignored). + +Always reconfirm with `fessctl documents bulk --help`. + +## Document fields +`url` and `title` are required. When a document leaves them out, the server fills `role` (the guest roles), `boost` (1.0), `content_length`, `click_count`, `favorite_count`, `last_modified` and `timestamp`. Any other index field (`title`, `content`, `label`, `lang`, `mimetype`, ...) is passed through. Arrays (`role`, `label`, `lang`, `anchor`, `virtual_host`) must be JSON arrays. +```json +{"url": "https://www.example.com/a.html", "title": "A page", "content": "text", "label": ["docs"]} +``` + +## Response shape +```json +{ + "response": { + "status": 0, + "items": [ + {"result": "CREATED", "id": "0ed4f2...e6e1"}, + {"result": "BAD_REQUEST", "message": "mapper_parsing_exception ..."} + ] + } +} +``` +`items` lists one entry per submitted document, in order. `id` is the index `_id`, not the `doc_id` that `fessctl searchlist` uses. + +## Gotchas +- Validation is all-or-nothing: a document without `url` or `title`, or with a value that does not convert (`boost` or `click_count` that is not a number, for example), fails the whole request with status 1 and nothing is indexed. +- Failures at indexing time (a value the index mapping cannot parse) fail only that document. The other documents are indexed, the response has `status` 9, `fessctl` prints every item and exits with code 9. +- The document `_id` is derived from the URL and the role list, so indexing the same URL again with the same roles replaces the document, while the same URL with different roles is a second document. +- `bulk` sends everything in a single request with a short client timeout; split very large files into several calls. +- Documents become searchable about a second later (index refresh). +- There is no delete in this API. Remove what you indexed with `fessctl searchlist delete-by-query`. + +## Examples +```bash +fessctl documents bulk docs.json +fessctl documents bulk docs.ndjson --output json +cat docs.ndjson | fessctl documents bulk - +``` diff --git a/skills/fessctl/references/features/searchlist.md b/skills/fessctl/references/features/searchlist.md new file mode 100644 index 0000000..e85ea54 --- /dev/null +++ b/skills/fessctl/references/features/searchlist.md @@ -0,0 +1,68 @@ +# Indexed documents (fessctl `searchlist`) + +## What it is +The admin document browser: search the documents that are in the Fess index, read one in full, and add, change or delete documents without a crawl. The Admin UI offers the same list as its Search List page. Every document is addressed by its `doc_id`; the search index `_id` shown in raw responses is a different value and is not accepted by `get`, `update` or `delete`. + +Available in every Fess version fessctl supports (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). + +## When to use +- Check what a crawl indexed: `search "title:foo"`, then `get `. +- Remove a document that should not be searchable: `delete `, or `delete-by-query` for a set of them. +- Add or correct a single document by hand: `create` / `update`. For many documents use `fessctl documents bulk`. + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `search` | Search the index. `--start` is the 0-based offset, `--size` the page size, `--sort`, `--lang` (repeatable). | none (a blank query matches all documents) | +| `get` | Show one document. | `` | +| `create` | Index a new document. | `--url`, `--title`, `--role` (repeatable); `--boost` defaults to 1.0 | +| `update` | Change fields of a document; every field not given is kept. | `` and at least one field option | +| `delete` | Delete one document. | `` | +| `delete-by-query` | Delete every document matching a query. Asks for confirmation unless `--yes` is given. | `` | + +`create` and `update` take the same field options: `--url`, `--title`, `--content`, `--role`, `--boost`, `--label`, plus `--field KEY=VALUE` (repeatable) for any other field, for example `--field mimetype=text/plain`. `--role` and `--label` replace the whole list. Always reconfirm with `fessctl searchlist --help`. + +## Resource JSON shape +`get` returns the stored document under `response.doc`; `search` returns hits under `response.docs` with the paging fields beside them. +```json +{ + "response": { + "status": 0, + "doc": { + "doc_id": "104d050992cb4992810ef340fba1c566", + "url": "https://www.example.com/a.html", + "title": "A page", + "content": "...", + "role": ["Rguest"], + "label": ["docs"], + "boost": 1.0, + "_id": "125cc2...", + "_version": 2 + } + } +} +``` +`search` reports `record_count` (total hits), `page_size`, `page_number`, `start_record_number` and `end_record_number`. `create` answers `{"id": "", "created": true}`; `delete-by-query` answers `{"count": N}`. + +## Gotchas +- The server requires `url`, `title`, `role` and `boost` on every create and update request (`index.admin.required.fields`). `update` reads the stored document first and sends those four back unchanged, so you only give what changes. A stored document that lacks one of them (for example one without a title) is refused unless you set it in the same call. +- `--role` is stored as given: `Rguest` is the guest role, and a document is searchable by a user only when it shares a role with them. The document's index `_id` is derived from its URL and role list, so changing `--url` or `--role` moves the document to a new `_id`; the old one is removed and the `doc_id` stays the same. +- `search` does not take `--page`; this API pages with `--start` and `--size`. The server caps `--size` at 100 (`paging.search.page.max.size`; 0 or less also means 100) and refuses an offset beyond `indexer.max.result.window.size` (10000 by default) with "Could not process the specified query". +- `--sort` accepts only sortable index fields such as `last_modified.desc`, `created`, `timestamp`, `content_length` and `click_count`; `title.desc` is refused. +- Documents are searchable about a second after they are written (index refresh). Poll `search` or `get` after `create`, `update` or a bulk index instead of expecting the result at once. The same delay applies to deletes: for a moment after `delete`, a second `delete` of the same `doc_id` can still report success. +- `create` does not fill defaults: `last_modified`, `timestamp`, `content_length`, `click_count` and `favorite_count` are absent unless you pass them with `--field` (`documents bulk` does fill them). +- Field values given with `--field` are sent as strings; the server converts the numeric and date fields it validates (`boost`, `click_count`, `expires`, ...) and refuses ones that do not convert. +- `delete-by-query` takes a normal Fess query (`*:*` matches everything). `url` is matched whole, so use `url:"https://..."` with the exact URL; a wildcard on it matches nothing. A blank query is refused by the server. A confirmation is asked first; pass `--yes` in scripts. +- Fess 15.8.0 and later refuse a `--content` change on a document whose content was split into chunks by the chunk-embedding pipeline, and drop `content_chunk_vector` and `content_chunk_status` given through `--field`; 15.9.0 also drops the tag field. +- Fess 15.9.0 reports "not found" when `delete` matches no `doc_id`; 14.19.2 through 15.8.0 answer success without deleting anything. + +## Examples +```bash +fessctl searchlist search "title:release" --size 20 +fessctl searchlist search "*:*" --start 100 --size 100 --output json +fessctl searchlist get DOC_ID +fessctl searchlist create --url https://www.example.com/note --title "Note" --role Rguest --content "hello" --label notes +fessctl searchlist update DOC_ID --title "New title" --boost 2 +fessctl searchlist delete DOC_ID +fessctl searchlist delete-by-query 'url:"https://www.example.com/old/page.html"' --yes +``` diff --git a/src/fessctl/api/client.py b/src/fessctl/api/client.py index 0d837c3..d8d4f7a 100644 --- a/src/fessctl/api/client.py +++ b/src/fessctl/api/client.py @@ -89,9 +89,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: + # Delete-by-query binds its query from a JSON body. + 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. @@ -712,6 +718,80 @@ 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}) + # Searchlist (document) APIs + + def search_documents( + self, + query: Optional[str] = None, + start: Optional[int] = None, + size: Optional[int] = None, + sort: Optional[str] = None, + lang: Optional[List[str]] = None, + ) -> dict: + """ + Searches the indexed documents. start is the 0-based offset of the first hit; + the server caps size at paging.search.page.max.size (100 by default). + """ + url = f"{self.base_url}/api/admin/searchlist/docs" + params = { + key: value + for key, value in ( + ("q", query), + ("start", start), + ("size", size), + ("sort", sort), + ("lang", lang), + ) + if value is not None + } + return self.send_request(Action.LIST, url, params=params) + + def get_document(self, doc_id: str) -> dict: + """ + Retrieves an indexed document by its doc_id. + """ + url = f"{self.base_url}/api/admin/searchlist/doc/{doc_id}" + return self.send_request(Action.GET, url) + + def create_document(self, doc: dict) -> dict: + """ + Indexes a new document. The server requires url, title, role and boost. + """ + url = f"{self.base_url}/api/admin/searchlist/doc" + return self.send_request(Action.CREATE, url, json_data={"doc": doc}) + + def update_document(self, doc: dict) -> dict: + """ + Updates an indexed document identified by doc["doc_id"]. The fields in doc are + merged over the stored document, but the server validates doc on its own, so + url, title, role and boost must be present. + """ + url = f"{self.base_url}/api/admin/searchlist/doc" + return self.send_request(Action.EDIT, url, json_data={"doc": doc}) + + def delete_document(self, doc_id: str) -> dict: + """ + Deletes an indexed document by its doc_id. + """ + url = f"{self.base_url}/api/admin/searchlist/doc/{doc_id}" + return self.send_request(Action.DELETE, url) + + def delete_documents_by_query(self, query: str) -> dict: + """ + Deletes every indexed document matching the query. + """ + url = f"{self.base_url}/api/admin/searchlist/query" + return self.send_request(Action.DELETE, url, json_data={"q": query}) + + # Documents APIs + + def bulk_index_documents(self, documents: List[dict]) -> dict: + """ + Indexes several documents in one request (POST on Fess 14, PUT on 15). + """ + url = f"{self.base_url}/api/admin/documents/bulk" + return self.send_request(Action.EDIT, url, json_data={"documents": documents}) + # WebAuth APIs def create_webauth(self, config: dict) -> dict: diff --git a/src/fessctl/cli.py b/src/fessctl/cli.py index 9fbe1df..7c100e0 100644 --- a/src/fessctl/cli.py +++ b/src/fessctl/cli.py @@ -12,7 +12,7 @@ from fessctl.commands.boostdoc import boostdoc_app from fessctl.commands.crawlinginfo import crawlinginfo_app from fessctl.commands.dataconfig import dataconfig_app -# from fessctl.commands.documents import documents_app +from fessctl.commands.documents import documents_app from fessctl.commands.duplicatehost import duplicatehost_app from fessctl.commands.elevateword import elevateword_app from fessctl.commands.failureurl import failureurl_app @@ -30,7 +30,7 @@ from fessctl.commands.relatedcontent import relatedcontent_app from fessctl.commands.relatedquery import relatedquery_app from fessctl.commands.reqheader import reqheader_app -# from fessctl.commands.searchlist import searchlist_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.storage import storage_app @@ -50,7 +50,7 @@ app.add_typer(boostdoc_app, name="boostdoc") app.add_typer(crawlinginfo_app, name="crawlinginfo") app.add_typer(dataconfig_app, name="dataconfig") -# app.add_typer(documents_app, name="documents") +app.add_typer(documents_app, name="documents") app.add_typer(duplicatehost_app, name="duplicatehost") app.add_typer(elevateword_app, name="elevateword") app.add_typer(failureurl_app, name="failureurl") @@ -68,7 +68,7 @@ app.add_typer(relatedcontent_app, name="relatedcontent") app.add_typer(relatedquery_app, name="relatedquery") app.add_typer(reqheader_app, name="reqheader") -# app.add_typer(searchlist_app, name="searchlist") +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") diff --git a/src/fessctl/commands/documents.py b/src/fessctl/commands/documents.py new file mode 100644 index 0000000..114e947 --- /dev/null +++ b/src/fessctl/commands/documents.py @@ -0,0 +1,74 @@ +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 + +documents_app = typer.Typer() + + +def _parse_documents(text: str) -> list: + """ + Accepts a JSON array of documents, {"documents": [...]}, a single document + object, or NDJSON (one document per line). + """ + try: + data = json.loads(text) + except json.JSONDecodeError: + data = [json.loads(line) for line in text.splitlines() if line.strip()] + if isinstance(data, dict): + data = data["documents"] if "documents" in data else [data] + if not isinstance(data, list) or not all(isinstance(d, dict) for d in data): + raise ValueError("expected a JSON array of document objects") + return data + + +@documents_app.command("bulk") +def bulk_index( + file: str = typer.Argument( + ..., help="File with the documents (a JSON array, {\"documents\": [...]} or NDJSON), or - for stdin"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Index many documents in one request. + + Each document needs a url and a title; role, boost, the counters and the + timestamps are filled in by the server when omitted. + """ + try: + text = typer.get_text_stream("stdin").read() if file == "-" else Path(file).read_text(encoding="utf-8") + documents = _parse_documents(text) + except (OSError, ValueError) as e: + typer.echo(format_result_markdown(False, f"Cannot read documents from '{file}'. {e}", "Document", "bulk")) + raise typer.Exit(code=1) + + client = FessAPIClient(Settings()) + result = client.bulk_index_documents(documents) + 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"{len(response.get('items', []))} document(s) indexed.", "Document", "bulk")) + else: + message: str = response.get("message", "") + items = response.get("items", []) + if items: + typer.echo(format_list_markdown("Bulk results", [dict(item, index=i + 1) for i, item in enumerate(items)], [ + ("#", "index"), + ("RESULT", "result"), + ("ID", "id"), + ("MESSAGE", "message"), + ])) + typer.echo(format_result_markdown(False, f"Failed to index documents. {message} Status code: {status}", "Document", "bulk")) + if status != 0: + raise typer.Exit(code=status) diff --git a/src/fessctl/commands/searchlist.py b/src/fessctl/commands/searchlist.py new file mode 100644 index 0000000..576cc10 --- /dev/null +++ b/src/fessctl/commands/searchlist.py @@ -0,0 +1,302 @@ +import json +from typing import List, Optional + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_detail_markdown, format_list_markdown, format_result_markdown + +searchlist_app = typer.Typer() + +# The fields the server requires on every create and update request. +_REQUIRED_FIELDS = ("url", "title", "role", "boost") + + +def _parse_fields(fields: Optional[List[str]]) -> dict: + parsed = {} + for item in fields or []: + key, sep, value = item.partition("=") + if not sep or not key: + typer.echo(format_result_markdown(False, f"Invalid --field '{item}'. Use KEY=VALUE.", "Document", "field")) + raise typer.Exit(code=1) + parsed[key] = value + return parsed + + +def _collect_changes(url, title, content, role, boost, label, fields) -> dict: + changes = { + key: value + for key, value in ( + ("url", url), + ("title", title), + ("content", content), + ("role", role or None), + ("boost", boost), + ("label", label or None), + ) + if value is not None + } + changes.update(_parse_fields(fields)) + return changes + + +def _join(value) -> str: + if isinstance(value, list): + return ", ".join(str(v) for v in value) + return value + + +def _shorten(value) -> str: + if isinstance(value, str) and len(value) > 200: + return value[:200] + "..." + return value + + +def _failed(message: str, status: int, action: str, what: str): + typer.echo(format_result_markdown(False, f"Failed to {what}. {message} Status code: {status}", "Document", action)) + raise typer.Exit(code=status) + + +def _emit(result: dict, output: str) -> bool: + if output == "json": + typer.echo(json.dumps(result, indent=2)) + return True + if output == "yaml": + typer.echo(yaml.dump(result)) + return True + return False + + +@searchlist_app.command("search") +def search_documents( + query: str = typer.Argument( + None, help="Search query (default: all documents)"), + start: int = typer.Option( + None, "--start", help="0-based offset of the first hit"), + size: int = typer.Option( + None, "--size", "-s", help="Number of hits to return (the server caps it at 100)"), + sort: str = typer.Option( + None, "--sort", help="Sort expression, e.g. last_modified.desc"), + lang: List[str] = typer.Option( + None, "--lang", help="Restrict to a language (repeatable)"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Search the indexed documents (admin document browser). + """ + client = FessAPIClient(Settings()) + result = client.search_documents(query=query, start=start, size=size, sort=sort, lang=lang or None) + response = result.get("response", {}) + status = response.get("status", 1) + + if not _emit(result, output): + if status == 0: + docs = response.get("docs", []) + if not docs: + typer.echo("No documents found.") + else: + typer.echo(format_list_markdown( + f"Documents ({response.get('start_record_number')}-{response.get('end_record_number')}" + f" of {response.get('record_count')})", + docs, + [ + ("DOC ID", "doc_id"), + ("TITLE", "title"), + ("URL", "url"), + ("LAST MODIFIED", "last_modified"), + ], + )) + else: + _failed(response.get("message", ""), status, "search", "search documents") + elif status != 0: + raise typer.Exit(code=status) + + +@searchlist_app.command("get") +def get_document( + doc_id: str = typer.Argument(..., help="Document doc_id"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Retrieve an indexed document by its doc_id. + """ + client = FessAPIClient(Settings()) + result = client.get_document(doc_id) + status = result.get("response", {}).get("status", 1) + + if not _emit(result, output): + if status == 0: + doc = result.get("response", {}).get("doc", {}) + typer.echo(format_detail_markdown( + f"Document Details: {doc.get('title', '-')}", + doc, + [ + ("doc_id", "doc_id"), + ("url", "url"), + ("title", "title"), + ("content", "content"), + ("role", "role"), + ("label", "label"), + ("lang", "lang"), + ("boost", "boost"), + ("mimetype", "mimetype"), + ("content_length", "content_length"), + ("click_count", "click_count"), + ("favorite_count", "favorite_count"), + ("last_modified", "last_modified"), + ("timestamp", "timestamp"), + ], + transforms={"content": _shorten, "role": _join, "label": _join, "lang": _join}, + )) + else: + message: str = result.get("response", {}).get("message", "") + _failed(message, status, "get", "retrieve document") + elif status != 0: + raise typer.Exit(code=status) + + +@searchlist_app.command("create") +def create_document( + url: str = typer.Option(..., "--url", help="Document URL"), + title: str = typer.Option(..., "--title", help="Document title"), + role: List[str] = typer.Option( + ..., "--role", help="Role allowed to see the document, e.g. Rguest (repeatable)"), + content: str = typer.Option(None, "--content", help="Document content"), + boost: float = typer.Option(1.0, "--boost", help="Boost value"), + label: List[str] = typer.Option( + None, "--label", help="Label value (repeatable)"), + field: List[str] = typer.Option( + None, "--field", help="Any other field as KEY=VALUE (repeatable), e.g. mimetype=text/plain"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Index a new document. + """ + doc = _collect_changes(url, title, content, role, boost, label, field) + client = FessAPIClient(Settings()) + result = client.create_document(doc) + status = result.get("response", {}).get("status", 1) + + if not _emit(result, output): + if status == 0: + doc_id = result.get("response", {}).get("id", "") + typer.echo(format_result_markdown(True, f"Document '{doc_id}' created successfully.", "Document", "create", doc_id)) + else: + message: str = result.get("response", {}).get("message", "") + _failed(message, status, "create", "create document") + elif status != 0: + raise typer.Exit(code=status) + + +@searchlist_app.command("update") +def update_document( + doc_id: str = typer.Argument(..., help="Document doc_id"), + url: str = typer.Option(None, "--url", help="New document URL"), + title: str = typer.Option(None, "--title", help="New title"), + content: str = typer.Option(None, "--content", help="New content"), + role: List[str] = typer.Option( + None, "--role", help="Replace the roles (repeatable)"), + boost: float = typer.Option(None, "--boost", help="New boost value"), + label: List[str] = typer.Option( + None, "--label", help="Replace the labels (repeatable)"), + field: List[str] = typer.Option( + None, "--field", help="Any other field as KEY=VALUE (repeatable)"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Update an indexed document. Only the given fields change. + """ + changes = _collect_changes(url, title, content, role, boost, label, field) + if not changes: + typer.echo(format_result_markdown(False, "Nothing to update. Give at least one field option.", "Document", "update", doc_id)) + raise typer.Exit(code=1) + + # The server validates the request body on its own: url, title, role and boost + # must be present even when they do not change, so send the stored values back. + client = FessAPIClient(Settings()) + current = client.get_document(doc_id) + current_status = current.get("response", {}).get("status", 1) + if current_status != 0: + message: str = current.get("response", {}).get("message", "") + _failed(message, current_status, "update", "retrieve document") + stored = current.get("response", {}).get("doc", {}) + doc = {key: stored[key] for key in _REQUIRED_FIELDS if key in stored} + doc["doc_id"] = doc_id + doc.update(changes) + + result = client.update_document(doc) + status = result.get("response", {}).get("status", 1) + + if not _emit(result, output): + if status == 0: + typer.echo(format_result_markdown(True, f"Document '{doc_id}' updated successfully.", "Document", "update", doc_id)) + else: + message = result.get("response", {}).get("message", "") + _failed(message, status, "update", "update document") + elif status != 0: + raise typer.Exit(code=status) + + +@searchlist_app.command("delete") +def delete_document( + doc_id: str = typer.Argument(..., help="Document doc_id"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Delete an indexed document by its doc_id. + """ + client = FessAPIClient(Settings()) + result = client.delete_document(doc_id) + status = result.get("response", {}).get("status", 1) + + if not _emit(result, output): + if status == 0: + typer.echo(format_result_markdown(True, f"Document '{doc_id}' deleted successfully.", "Document", "delete", doc_id)) + else: + message: str = result.get("response", {}).get("message", "") + _failed(message, status, "delete", "delete document") + elif status != 0: + raise typer.Exit(code=status) + + +@searchlist_app.command("delete-by-query") +def delete_documents_by_query( + query: str = typer.Argument(..., help="Delete every document matching this query"), + 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 indexed document matching a query. + """ + if not yes: + typer.confirm(f"Delete all documents matching '{query}'?", abort=True) + + client = FessAPIClient(Settings()) + result = client.delete_documents_by_query(query) + status = result.get("response", {}).get("status", 1) + + if not _emit(result, output): + if status == 0: + count = result.get("response", {}).get("count", 0) + typer.echo(format_result_markdown(True, f"{count} document(s) deleted.", "Document", "delete-by-query")) + else: + message: str = result.get("response", {}).get("message", "") + _failed(message, status, "delete-by-query", "delete documents") + elif status != 0: + raise typer.Exit(code=status) diff --git a/tests/commands/test_documents.py b/tests/commands/test_documents.py new file mode 100644 index 0000000..bcdc53a --- /dev/null +++ b/tests/commands/test_documents.py @@ -0,0 +1,99 @@ +import json +import time +import uuid + +import pytest +from typer.testing import CliRunner + +from fessctl.cli import app +from fessctl.commands.searchlist import searchlist_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def _hits(runner, query): + result = runner.invoke(searchlist_app, ["search", query, "--output", "json"]) + assert result.exit_code == 0, f"Search failed: {result.stdout}" + return json.loads(result.stdout)["response"]["record_count"] + + +def _wait_for_count(runner, query, count, timeout=30): + deadline = time.time() + timeout + while _hits(runner, query) != count: + assert time.time() < deadline, f"'{query}' did not reach {count} hits" + time.sleep(1) + + +@pytest.fixture +def token(runner, fess_service): + """ + A unique search token for the documents of one test. Bulk results report the + index _id, not the doc_id, so the documents are removed by query afterwards. + """ + value = f"fessctl{uuid.uuid4().hex}" + yield value + result = runner.invoke(searchlist_app, ["delete-by-query", value, "--yes"]) + assert result.exit_code == 0, f"Cleanup failed: {result.stdout}" + _wait_for_count(runner, value, 0) + + +def _doc(token, name, **extra): + return {"url": f"http://{token}.invalid/{name}", "title": f"{token} {name}", + "content": f"{token} content of {name}", **extra} + + +def test_documents_bulk_json_array(runner, token, tmp_path): + path = tmp_path / "docs.json" + path.write_text(json.dumps([_doc(token, "a"), _doc(token, "b", label=["fessctl-it"])])) + + result = runner.invoke(app, ["documents", "bulk", str(path), "--output", "json"]) + assert result.exit_code == 0, f"Bulk failed: {result.stdout}" + resp = json.loads(result.stdout)["response"] + assert resp.get("status") == 0 + assert [item["result"] for item in resp["items"]] == ["CREATED", "CREATED"] + + _wait_for_count(runner, token, 2) + + +def test_documents_bulk_ndjson_and_stdin(runner, token): + ndjson = "\n".join(json.dumps(_doc(token, name)) for name in ("a", "b", "c")) + "\n" + + result = runner.invoke(app, ["documents", "bulk", "-"], input=ndjson) + assert result.exit_code == 0, f"Bulk failed: {result.stdout}" + assert "3 document(s) indexed." in result.stdout + + _wait_for_count(runner, token, 3) + + +def test_documents_bulk_partial_failure(runner, token, tmp_path): + # a document whose field cannot be mapped fails alone; the others are indexed + path = tmp_path / "docs.ndjson" + path.write_text("\n".join(json.dumps(d) for d in (_doc(token, "good"), _doc(token, "bad", mimetype={"a": 1})))) + + result = runner.invoke(app, ["documents", "bulk", str(path)]) + assert result.exit_code != 0 + assert "CREATED" in result.stdout + assert "BAD_REQUEST" in result.stdout + + _wait_for_count(runner, token, 1) + + +def test_documents_bulk_validation_error(runner, fess_service, tmp_path): + path = tmp_path / "docs.json" + path.write_text(json.dumps([{"title": "no url"}])) + + result = runner.invoke(app, ["documents", "bulk", str(path)]) + assert result.exit_code != 0 + assert "failed to index documents" in result.stdout.lower() + + +def test_documents_bulk_missing_file(runner, fess_service, tmp_path): + result = runner.invoke(app, ["documents", "bulk", str(tmp_path / "missing.json")]) + assert result.exit_code != 0 + assert "cannot read documents" in result.stdout.lower() diff --git a/tests/commands/test_searchlist.py b/tests/commands/test_searchlist.py new file mode 100644 index 0000000..374ef14 --- /dev/null +++ b/tests/commands/test_searchlist.py @@ -0,0 +1,179 @@ +import json +import time +import uuid + +import pytest +from typer.testing import CliRunner + +from conftest import fess_version_at_least +from fessctl.commands.searchlist import searchlist_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def _search(runner, query, **options): + args = ["search", query, "--output", "json"] + for key, value in options.items(): + args += [f"--{key}", str(value)] + result = runner.invoke(searchlist_app, args) + assert result.exit_code == 0, f"Search failed: {result.stdout}" + return json.loads(result.stdout)["response"] + + +def _wait_for_count(runner, query, count, timeout=30): + """ + Newly indexed documents become searchable only after an index refresh, + so poll until the query returns the expected number of hits. + """ + deadline = time.time() + timeout + while True: + response = _search(runner, query) + if response.get("record_count") == count: + return response + assert time.time() < deadline, f"'{query}' still returns {response.get('record_count')} hits, wanted {count}" + time.sleep(1) + + +@pytest.fixture +def token(runner, fess_service): + """ + A unique search token for the documents of one test. Everything matching it + is removed afterwards, even when the test fails. + """ + value = f"fessctl{uuid.uuid4().hex}" + yield value + result = runner.invoke(searchlist_app, ["delete-by-query", value, "--yes"]) + assert result.exit_code == 0, f"Cleanup failed: {result.stdout}" + _wait_for_count(runner, value, 0) + + +def _create(runner, token, name, *extra): + result = runner.invoke(searchlist_app, [ + "create", "--url", f"http://{token}.invalid/{name}", "--title", f"{token} {name}", + "--role", "Rguest", "--content", f"{token} content of {name}", *extra, "--output", "json", + ]) + assert result.exit_code == 0, f"Create failed: {result.stdout}" + resp = json.loads(result.stdout)["response"] + assert resp.get("status") == 0 + assert resp.get("created") is True + return resp["id"] + + +def test_searchlist_create_search_get_update_delete(runner, token): + doc_id = _create(runner, token, "one", "--label", "fessctl-it", "--field", "mimetype=text/plain") + + response = _wait_for_count(runner, token, 1) + assert response["docs"][0]["doc_id"] == doc_id + + result = runner.invoke(searchlist_app, ["get", doc_id, "--output", "json"]) + assert result.exit_code == 0, f"Get failed: {result.stdout}" + doc = json.loads(result.stdout)["response"]["doc"] + assert doc["url"] == f"http://{token}.invalid/one" + assert doc["role"] == ["Rguest"] + assert doc["boost"] == 1.0 + assert doc["label"] == ["fessctl-it"] + assert doc["mimetype"] == "text/plain" + + result = runner.invoke(searchlist_app, ["get", doc_id]) + assert result.exit_code == 0 + assert f"| doc_id | {doc_id} |" in result.stdout + + # update changes only the given fields; the others are kept + result = runner.invoke(searchlist_app, ["update", doc_id, "--title", f"{token} renamed", "--boost", "2.5"]) + assert result.exit_code == 0, f"Update failed: {result.stdout}" + result = runner.invoke(searchlist_app, ["get", doc_id, "--output", "json"]) + updated = json.loads(result.stdout)["response"]["doc"] + assert updated["title"] == f"{token} renamed" + assert updated["boost"] == 2.5 + assert updated["url"] == doc["url"] + assert updated["role"] == ["Rguest"] + assert updated["label"] == ["fessctl-it"] + assert updated["content"] == doc["content"] + + result = runner.invoke(searchlist_app, ["delete", doc_id]) + assert result.exit_code == 0, f"Delete failed: {result.stdout}" + _wait_for_count(runner, token, 0) + + result = runner.invoke(searchlist_app, ["get", doc_id]) + assert result.exit_code != 0 + assert "failed to retrieve document" in result.stdout.lower() + + +def test_searchlist_search_paging(runner, token): + for name in ("a", "b", "c"): + _create(runner, token, name) + _wait_for_count(runner, token, 3) + + first = _search(runner, token, size=2) + assert first["page_size"] == 2 + assert len(first["docs"]) == 2 + assert first["record_count"] == 3 + + second = _search(runner, token, size=2, start=2) + assert len(second["docs"]) == 1 + seen = {d["doc_id"] for d in first["docs"]} | {d["doc_id"] for d in second["docs"]} + assert len(seen) == 3 + + result = runner.invoke(searchlist_app, ["search", token]) + assert result.exit_code == 0 + assert "of 3)" in result.stdout + + +def test_searchlist_delete_by_query(runner, token): + for name in ("a", "b"): + _create(runner, token, name) + _wait_for_count(runner, token, 2) + + # a declined confirmation deletes nothing + result = runner.invoke(searchlist_app, ["delete-by-query", token], input="n\n") + assert result.exit_code != 0 + assert _search(runner, token)["record_count"] == 2 + + result = runner.invoke(searchlist_app, ["delete-by-query", token, "--yes", "--output", "json"]) + assert result.exit_code == 0, f"Delete by query failed: {result.stdout}" + resp = json.loads(result.stdout)["response"] + assert resp.get("status") == 0 + assert resp.get("count") == 2 + _wait_for_count(runner, token, 0) + + +def test_searchlist_get_not_found(runner, fess_service): + result = runner.invoke(searchlist_app, ["get", "no-such-doc-id"]) + assert result.exit_code != 0 + assert "failed to retrieve document" in result.stdout.lower() + + +def test_searchlist_update_not_found(runner, fess_service): + result = runner.invoke(searchlist_app, ["update", "no-such-doc-id", "--title", "x"]) + assert result.exit_code != 0 + assert "failed to retrieve document" in result.stdout.lower() + + +def test_searchlist_create_validation_error(runner, fess_service): + # url, title, role and boost are required; a non-numeric long field is refused + result = runner.invoke(searchlist_app, [ + "create", "--url", "http://fessctl-invalid.invalid/", "--title", "t", "--role", "Rguest", + "--field", "click_count=abc", + ]) + assert result.exit_code != 0 + assert "failed to create document" in result.stdout.lower() + + +def test_searchlist_delete_not_found(runner, fess_service): + if not fess_version_at_least(15, 9): + pytest.skip("Fess before 15.9 answers a delete of an unknown doc_id with success") + result = runner.invoke(searchlist_app, ["delete", "no-such-doc-id"]) + assert result.exit_code != 0 + assert "failed to delete document" in result.stdout.lower() + + +def test_searchlist_delete_by_query_blank_query_refused(runner, fess_service): + result = runner.invoke(searchlist_app, ["delete-by-query", "", "--yes"]) + assert result.exit_code != 0 + assert "failed to delete documents" in result.stdout.lower() diff --git a/tests/unit/test_documents.py b/tests/unit/test_documents.py new file mode 100644 index 0000000..8258c26 --- /dev/null +++ b/tests/unit/test_documents.py @@ -0,0 +1,109 @@ +""" +Unit tests for the documents command. documents has a single subcommand, so a +bare Typer app collapses into it; invoke it through the real CLI instead. +""" +import json +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from fessctl.cli import app + + +@pytest.fixture +def runner(): + return CliRunner() + + +DOC1 = {"url": "http://e/1", "title": "one"} +DOC2 = {"url": "http://e/2", "title": "two"} + + +class TestDocumentsBulk: + @pytest.mark.parametrize("text", [ + json.dumps([DOC1, DOC2]), + json.dumps({"documents": [DOC1, DOC2]}), + json.dumps(DOC1) + "\n\n" + json.dumps(DOC2) + "\n", + ]) + @patch("fessctl.commands.documents.FessAPIClient") + def test_accepts_array_wrapper_and_ndjson(self, mock_client_class, runner, tmp_path, text): + mock_client = mock_client_class.return_value + mock_client.bulk_index_documents.return_value = {"response": {"status": 0, "items": [{}, {}]}} + path = tmp_path / "docs" + path.write_text(text) + + result = runner.invoke(app, ["documents", "bulk", str(path)]) + + assert result.exit_code == 0 + assert "2 document(s) indexed." in result.stdout + mock_client.bulk_index_documents.assert_called_once_with([DOC1, DOC2]) + + @patch("fessctl.commands.documents.FessAPIClient") + def test_single_document_object(self, mock_client_class, runner, tmp_path): + mock_client = mock_client_class.return_value + mock_client.bulk_index_documents.return_value = {"response": {"status": 0, "items": [{}]}} + path = tmp_path / "doc.json" + path.write_text(json.dumps(DOC1)) + + runner.invoke(app, ["documents", "bulk", str(path)]) + + mock_client.bulk_index_documents.assert_called_once_with([DOC1]) + + @patch("fessctl.commands.documents.FessAPIClient") + def test_reads_stdin(self, mock_client_class, runner): + mock_client = mock_client_class.return_value + mock_client.bulk_index_documents.return_value = {"response": {"status": 0, "items": [{}]}} + + result = runner.invoke(app, ["documents", "bulk", "-"], input=json.dumps([DOC1])) + + assert result.exit_code == 0 + mock_client.bulk_index_documents.assert_called_once_with([DOC1]) + + @pytest.mark.parametrize("text", ["not json", "[1, 2]", '{"documents": "x"}']) + @patch("fessctl.commands.documents.FessAPIClient") + def test_rejects_unparsable_input_without_calling_the_api(self, mock_client_class, runner, tmp_path, text): + path = tmp_path / "bad" + path.write_text(text) + + result = runner.invoke(app, ["documents", "bulk", str(path)]) + + assert result.exit_code == 1 + assert "Cannot read documents" in result.stdout + mock_client_class.return_value.bulk_index_documents.assert_not_called() + + @patch("fessctl.commands.documents.FessAPIClient") + def test_missing_file(self, mock_client_class, runner, tmp_path): + result = runner.invoke(app, ["documents", "bulk", str(tmp_path / "missing.json")]) + + assert result.exit_code == 1 + assert "Cannot read documents" in result.stdout + mock_client_class.return_value.bulk_index_documents.assert_not_called() + + @patch("fessctl.commands.documents.FessAPIClient") + def test_partial_failure_lists_items_and_exits_with_status(self, mock_client_class, runner, tmp_path): + mock_client_class.return_value.bulk_index_documents.return_value = {"response": {"status": 9, "items": [ + {"result": "CREATED", "id": "abc"}, + {"result": "BAD_REQUEST", "message": "mapper_parsing_exception"}, + ]}} + path = tmp_path / "docs" + path.write_text(json.dumps([DOC1, DOC2])) + + result = runner.invoke(app, ["documents", "bulk", str(path)]) + + assert result.exit_code == 9 + assert "| 1 | CREATED | abc | - |" in result.stdout + assert "| 2 | BAD_REQUEST | - | mapper_parsing_exception |" in result.stdout + + @pytest.mark.parametrize("output", ["json", "yaml"]) + @patch("fessctl.commands.documents.FessAPIClient") + def test_api_failure_exits_with_status_in_every_format(self, mock_client_class, runner, tmp_path, output): + mock_client_class.return_value.bulk_index_documents.return_value = { + "response": {"status": 1, "message": "documents is empty."}} + path = tmp_path / "docs" + path.write_text("[]") + + result = runner.invoke(app, ["documents", "bulk", str(path), "--output", output]) + + assert result.exit_code == 1 + assert "documents is empty." in result.stdout diff --git a/tests/unit/test_searchlist.py b/tests/unit/test_searchlist.py new file mode 100644 index 0000000..569cbec --- /dev/null +++ b/tests/unit/test_searchlist.py @@ -0,0 +1,337 @@ +""" +Unit tests for the searchlist command and its client methods. +""" +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.api.client import FessAPIClient +from fessctl.commands.searchlist import searchlist_app +from fessctl.config.settings import Settings + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _client(version): + settings = Mock(spec=Settings) + settings.fess_endpoint = "http://fess" + settings.access_token = "token" + settings.fess_version = version + return FessAPIClient(settings) + + +@pytest.fixture +def http(): + response = Mock() + response.json.return_value = {"response": {"status": 0}} + with patch("httpx.get", return_value=response) as get, \ + patch("httpx.post", return_value=response) as post, \ + patch("httpx.put", return_value=response) as put, \ + patch("httpx.delete", return_value=response) as delete, \ + patch("httpx.request", return_value=response) as request: + yield Mock(get=get, post=post, put=put, delete=delete, request=request) + + +class TestSearchlistClient: + def test_search_sends_only_given_params_in_body_and_query(self, http): + _client("15.9.0").search_documents(query="foo", start=20, size=10, lang=["ja"]) + + assert http.request.call_args[0] == ("GET", "http://fess/api/admin/searchlist/docs") + expected = {"q": "foo", "start": 20, "size": 10, "lang": ["ja"]} + assert http.request.call_args[1]["json"] == expected + assert http.request.call_args[1]["params"] == expected + + def test_search_without_arguments_sends_empty_params(self, http): + _client("15.9.0").search_documents() + + assert http.request.call_args[1]["json"] == {} + + def test_get_and_delete_use_doc_id_path(self, http): + client = _client("15.9.0") + + client.get_document("abc") + client.delete_document("abc") + + assert http.get.call_args[0][0] == "http://fess/api/admin/searchlist/doc/abc" + assert http.delete.call_args[0][0] == "http://fess/api/admin/searchlist/doc/abc" + + def test_delete_by_query_sends_query_in_body(self, http): + _client("15.9.0").delete_documents_by_query("foo") + + assert http.request.call_args[0] == ("DELETE", "http://fess/api/admin/searchlist/query") + assert http.request.call_args[1]["json"] == {"q": "foo"} + http.delete.assert_not_called() + + def test_create_posts_and_update_puts_on_fess15(self, http): + client = _client("15.9.0") + + client.create_document({"url": "u"}) + client.update_document({"doc_id": "d"}) + + assert http.post.call_args[0][0] == "http://fess/api/admin/searchlist/doc" + assert http.post.call_args[1]["json"] == {"doc": {"url": "u"}} + assert http.put.call_args[0][0] == "http://fess/api/admin/searchlist/doc" + assert http.put.call_args[1]["json"] == {"doc": {"doc_id": "d"}} + + def test_create_puts_and_update_posts_on_fess14(self, http): + client = _client("14.19.2") + + client.create_document({"url": "u"}) + client.update_document({"doc_id": "d"}) + + assert http.put.call_args[1]["json"] == {"doc": {"url": "u"}} + assert http.post.call_args[1]["json"] == {"doc": {"doc_id": "d"}} + + def test_bulk_puts_on_fess15_and_posts_on_fess14(self, http): + _client("15.9.0").bulk_index_documents([{"url": "u"}]) + assert http.put.call_args[0][0] == "http://fess/api/admin/documents/bulk" + assert http.put.call_args[1]["json"] == {"documents": [{"url": "u"}]} + + _client("14.19.2").bulk_index_documents([{"url": "u"}]) + assert http.post.call_args[0][0] == "http://fess/api/admin/documents/bulk" + + +class TestSearchlistSearch: + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_search_passes_options(self, mock_client_class, runner): + mock_client = mock_client_class.return_value + mock_client.search_documents.return_value = {"response": {"status": 0, "docs": []}} + + result = runner.invoke( + searchlist_app, + ["search", "foo", "--start", "10", "--size", "5", "--sort", "last_modified.desc", "--lang", "ja"], + ) + + assert result.exit_code == 0 + assert "No documents found." in result.stdout + mock_client.search_documents.assert_called_once_with( + query="foo", start=10, size=5, sort="last_modified.desc", lang=["ja"]) + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_search_without_options_sends_nothing(self, mock_client_class, runner): + mock_client = mock_client_class.return_value + mock_client.search_documents.return_value = {"response": {"status": 0, "docs": []}} + + runner.invoke(searchlist_app, ["search"]) + + mock_client.search_documents.assert_called_once_with( + query=None, start=None, size=None, sort=None, lang=None) + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_search_text_output(self, mock_client_class, runner): + mock_client = mock_client_class.return_value + mock_client.search_documents.return_value = {"response": { + "status": 0, "record_count": 42, "start_record_number": 1, "end_record_number": 1, + "docs": [{"doc_id": "d1", "title": "T", "url": "http://e/a", "last_modified": "2026-10-16T00:00:00.000Z"}], + }} + + result = runner.invoke(searchlist_app, ["search", "foo"]) + + assert result.exit_code == 0 + assert "## Documents (1-1 of 42)" in result.stdout + assert "| d1 | T | http://e/a | 2026-10-16T00:00:00.000Z |" in result.stdout + + @patch("fessctl.commands.searchlist.FessAPIClient") + @pytest.mark.parametrize("output", ["text", "json", "yaml"]) + def test_search_failure_exits_with_status(self, mock_client_class, runner, output): + mock_client = mock_client_class.return_value + mock_client.search_documents.return_value = {"response": {"status": 1, "message": "boom"}} + + result = runner.invoke(searchlist_app, ["search", "foo", "--output", output]) + + assert result.exit_code == 1 + assert "boom" in result.stdout + + +class TestSearchlistGet: + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_get_text_output(self, mock_client_class, runner): + mock_client = mock_client_class.return_value + mock_client.get_document.return_value = {"response": {"status": 0, "doc": { + "doc_id": "d1", "url": "http://e/a", "title": "T", "role": ["Rguest", "1guest"], "boost": 1.0, + "content": "x" * 300, + }}} + + result = runner.invoke(searchlist_app, ["get", "d1"]) + + assert result.exit_code == 0 + assert "| role | Rguest, 1guest |" in result.stdout + assert "x" * 200 + "..." in result.stdout + assert "x" * 201 not in result.stdout + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_get_not_found(self, mock_client_class, runner): + mock_client_class.return_value.get_document.return_value = { + "response": {"status": 1, "message": "The data nope is not found."}} + + result = runner.invoke(searchlist_app, ["get", "nope"]) + + assert result.exit_code == 1 + assert "not found" in result.stdout + + +class TestSearchlistCreate: + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_create_sends_the_given_fields(self, mock_client_class, runner): + mock_client = mock_client_class.return_value + mock_client.create_document.return_value = {"response": {"status": 0, "id": "d1"}} + + result = runner.invoke(searchlist_app, [ + "create", "--url", "http://e/a", "--title", "T", "--role", "Rguest", "--role", "1guest", + "--content", "body", "--boost", "2", "--label", "L1", "--field", "mimetype=text/plain", + "--field", "a=b=c", + ]) + + assert result.exit_code == 0 + assert "Document 'd1' created successfully." in result.stdout + mock_client.create_document.assert_called_once_with({ + "url": "http://e/a", "title": "T", "role": ["Rguest", "1guest"], "content": "body", + "boost": 2.0, "label": ["L1"], "mimetype": "text/plain", "a": "b=c", + }) + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_create_rejects_malformed_field(self, mock_client_class, runner): + result = runner.invoke(searchlist_app, [ + "create", "--url", "u", "--title", "T", "--role", "r", "--field", "novalue"]) + + assert result.exit_code == 1 + assert "KEY=VALUE" in result.stdout + mock_client_class.return_value.create_document.assert_not_called() + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_create_failure_exits_with_status(self, mock_client_class, runner): + mock_client_class.return_value.create_document.return_value = { + "response": {"status": 1, "message": "Validation Error"}} + + result = runner.invoke(searchlist_app, ["create", "--url", "u", "--title", "T", "--role", "r"]) + + assert result.exit_code == 1 + assert "Validation Error" in result.stdout + + +class TestSearchlistUpdate: + STORED = {"response": {"status": 0, "doc": { + "doc_id": "d1", "url": "http://e/a", "title": "T", "role": ["Rguest"], "boost": 1.0, + "content": "body", "label": ["L1"], "_id": "x", "_version": 3, "_seq_no": -2, "_primary_term": 0, + }}} + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_update_sends_back_the_required_fields_with_the_change(self, mock_client_class, runner): + mock_client = mock_client_class.return_value + mock_client.get_document.return_value = self.STORED + mock_client.update_document.return_value = {"response": {"status": 0, "id": "d1"}} + + result = runner.invoke(searchlist_app, ["update", "d1", "--title", "New", "--field", "click_count=7"]) + + assert result.exit_code == 0 + assert "Document 'd1' updated successfully." in result.stdout + mock_client.update_document.assert_called_once_with({ + "doc_id": "d1", "url": "http://e/a", "title": "New", "role": ["Rguest"], "boost": 1.0, + "click_count": "7", + }) + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_update_replaces_role_and_label_lists(self, mock_client_class, runner): + mock_client = mock_client_class.return_value + mock_client.get_document.return_value = self.STORED + mock_client.update_document.return_value = {"response": {"status": 0, "id": "d1"}} + + runner.invoke(searchlist_app, ["update", "d1", "--role", "A", "--role", "B", "--label", "L2"]) + + sent = mock_client.update_document.call_args[0][0] + assert sent["role"] == ["A", "B"] + assert sent["label"] == ["L2"] + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_update_without_changes_does_not_call_the_api(self, mock_client_class, runner): + result = runner.invoke(searchlist_app, ["update", "d1"]) + + assert result.exit_code == 1 + assert "Nothing to update" in result.stdout + mock_client_class.return_value.get_document.assert_not_called() + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_update_of_missing_document_fails_before_updating(self, mock_client_class, runner): + mock_client = mock_client_class.return_value + mock_client.get_document.return_value = {"response": {"status": 1, "message": "not found"}} + + result = runner.invoke(searchlist_app, ["update", "nope", "--title", "x"]) + + assert result.exit_code == 1 + assert "not found" in result.stdout + mock_client.update_document.assert_not_called() + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_update_failure_exits_with_status(self, mock_client_class, runner): + mock_client = mock_client_class.return_value + mock_client.get_document.return_value = self.STORED + mock_client.update_document.return_value = {"response": {"status": 1, "message": "boom"}} + + result = runner.invoke(searchlist_app, ["update", "d1", "--title", "x"]) + + assert result.exit_code == 1 + assert "boom" in result.stdout + + +class TestSearchlistDelete: + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_delete_success(self, mock_client_class, runner): + mock_client_class.return_value.delete_document.return_value = {"response": {"status": 0}} + + result = runner.invoke(searchlist_app, ["delete", "d1"]) + + assert result.exit_code == 0 + assert "Document 'd1' deleted successfully." in result.stdout + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_delete_failure_exits_with_status(self, mock_client_class, runner): + mock_client_class.return_value.delete_document.return_value = { + "response": {"status": 1, "message": "not found"}} + + result = runner.invoke(searchlist_app, ["delete", "d1"]) + + assert result.exit_code == 1 + + +class TestSearchlistDeleteByQuery: + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_aborts_without_confirmation(self, mock_client_class, runner): + result = runner.invoke(searchlist_app, ["delete-by-query", "foo"], input="n\n") + + assert result.exit_code != 0 + mock_client_class.return_value.delete_documents_by_query.assert_not_called() + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_deletes_after_confirmation(self, mock_client_class, runner): + mock_client = mock_client_class.return_value + mock_client.delete_documents_by_query.return_value = {"response": {"status": 0, "count": 3}} + + result = runner.invoke(searchlist_app, ["delete-by-query", "foo"], input="y\n") + + assert result.exit_code == 0 + assert "3 document(s) deleted." in result.stdout + mock_client.delete_documents_by_query.assert_called_once_with("foo") + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_yes_skips_confirmation(self, mock_client_class, runner): + mock_client = mock_client_class.return_value + mock_client.delete_documents_by_query.return_value = {"response": {"status": 0, "count": 0}} + + result = runner.invoke(searchlist_app, ["delete-by-query", "foo", "--yes"]) + + assert result.exit_code == 0 + mock_client.delete_documents_by_query.assert_called_once_with("foo") + + @patch("fessctl.commands.searchlist.FessAPIClient") + def test_failure_exits_with_status(self, mock_client_class, runner): + mock_client_class.return_value.delete_documents_by_query.return_value = { + "response": {"status": 1, "message": "unknown condition"}} + + result = runner.invoke(searchlist_app, ["delete-by-query", "foo", "--yes"]) + + assert result.exit_code == 1 + assert "unknown condition" in result.stdout