diff --git a/skills/fessctl/SKILL.md b/skills/fessctl/SKILL.md index c5f6cad..eed8bde 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 34 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 36 resource types and cross-feature workflows like initial crawl setup. license: Apache-2.0 version: 0.1.0 --- @@ -65,6 +65,8 @@ Each file documents one Fess admin feature: what it is, when to use it, fessctl | Plugins | references/features/plugin.md | | Object storage | references/features/storage.md | | Dictionaries | references/features/dict.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/skills/fessctl/references/workflows.md b/skills/fessctl/references/workflows.md index 7e269c2..fe1c84d 100644 --- a/skills/fessctl/references/workflows.md +++ b/skills/fessctl/references/workflows.md @@ -193,9 +193,3 @@ fessctl badword create --suggest-word internal-test ``` **Caution:** several of these tunings take effect only after the suggest dictionary is rebuilt or the search index is reloaded. If changes do not appear in search results, trigger the matching maintenance scheduler job (inspect `fessctl scheduler list --output json | jq '.response.settings[] | select(.name | test("Suggest|Index"; "i"))'`). - ---- - -## Out of scope (today) - -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 c377290..114cbe3 100644 --- a/src/fessctl/api/client.py +++ b/src/fessctl/api/client.py @@ -114,7 +114,7 @@ def send_request( url, headers=headers, params=params, timeout=self.timeout ) else: - # httpx.delete cannot carry a body; some endpoints (plugin) bind it. + # httpx.delete cannot carry a body; some endpoints (plugin, delete-by-query) bind it. response = httpx.request( "DELETE", url, headers=headers, params=params, json=json_data, timeout=self.timeout ) @@ -873,6 +873,79 @@ 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}) # General APIs def get_general(self) -> dict: diff --git a/src/fessctl/cli.py b/src/fessctl/cli.py index 65ea5ef..4edc019 100644 --- a/src/fessctl/cli.py +++ b/src/fessctl/cli.py @@ -13,7 +13,7 @@ from fessctl.commands.crawlinginfo import crawlinginfo_app from fessctl.commands.dataconfig import dataconfig_app from fessctl.commands.dict import dict_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 @@ -31,7 +31,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 @@ -52,7 +52,7 @@ app.add_typer(crawlinginfo_app, name="crawlinginfo") app.add_typer(dataconfig_app, name="dataconfig") app.add_typer(dict_app, name="dict") -# 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") @@ -70,7 +70,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