From c874a4318c9bc855ade661909a4a5b0e46922906 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sun, 4 Oct 2026 23:08:14 +0900 Subject: [PATCH] test: cover the Tag admin screen Add a tagtype module for /admin/tagtype/: add (paths, virtual host, sort order, owner-default permission), list search by name and owner with reset, validation (blank name, blank owner, whitespace-only name, a name of ideographic spaces and an over-long name rejected by the tag-name check, duplicate name and owner), update with an owner change that moves the owner permission and the id, and delete with a converging absence check. The module is skipped on Fess older than 15.9. --- README.md | 1 + src/fess/test/i18n/keys.py | 9 ++ src/fess/test/i18n/message_keys.py | 11 ++ src/fess/test/ui/admin/tagtype/__init__.py | 27 ++++ src/fess/test/ui/admin/tagtype/_names.py | 30 +++++ src/fess/test/ui/admin/tagtype/add.py | 96 ++++++++++++++ src/fess/test/ui/admin/tagtype/delete.py | 80 ++++++++++++ src/fess/test/ui/admin/tagtype/search.py | 110 ++++++++++++++++ src/fess/test/ui/admin/tagtype/update.py | 121 +++++++++++++++++ src/fess/test/ui/admin/tagtype/validation.py | 130 +++++++++++++++++++ src/main.py | 4 +- 11 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 src/fess/test/ui/admin/tagtype/__init__.py create mode 100644 src/fess/test/ui/admin/tagtype/_names.py create mode 100644 src/fess/test/ui/admin/tagtype/add.py create mode 100644 src/fess/test/ui/admin/tagtype/delete.py create mode 100644 src/fess/test/ui/admin/tagtype/search.py create mode 100644 src/fess/test/ui/admin/tagtype/update.py create mode 100644 src/fess/test/ui/admin/tagtype/validation.py diff --git a/README.md b/README.md index a377900..a651a40 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ The test suite covers the following Fess admin functionality: - **Users & Groups**: User account and group management - **Roles**: Role-based access control - **Labels**: Search result labeling +- **Tags**: Per-user tag management (Fess 15.9+) - **Virtual Hosts**: Virtual host configuration ### Search Configuration diff --git a/src/fess/test/i18n/keys.py b/src/fess/test/i18n/keys.py index 62ae34d..28be27a 100644 --- a/src/fess/test/i18n/keys.py +++ b/src/fess/test/i18n/keys.py @@ -32,6 +32,7 @@ class Labels: MENU_FILE_SYSTEM = "labels.menu_file_system" MENU_DATA_STORE = "labels.menu_data_store" MENU_LABEL_TYPE = "labels.menu_label_type" + MENU_TAG_TYPE = "labels.menu_tag_type" MENU_KEY_MATCH = "labels.menu_key_match" MENU_BOOST_DOCUMENT_RULE = "labels.menu_boost_document_rule" MENU_RELATED_CONTENT = "labels.menu_related_content" @@ -72,6 +73,14 @@ class Labels: CRUD_BUTTON_BACK = "labels.crud_button_back" CRUD_BUTTON_EDIT = "labels.crud_button_edit" + # ---- CRUD list search form (collapsed #listSearchForm) ----------- + CRUD_BUTTON_SEARCH = "labels.crud_button_search" + CRUD_BUTTON_RESET = "labels.crud_button_reset" + + # ---- Tag (admin/tagtype) ----------------------------------------- + TAGTYPE_NAME = "labels.tagtype_name" + TAGTYPE_OWNER = "labels.tagtype_owner" + # ---- Scheduler --------------------------------------------------- SCHEDULER_BUTTON_START = "labels.scheduledjob_button_start" diff --git a/src/fess/test/i18n/message_keys.py b/src/fess/test/i18n/message_keys.py index 4300dd8..71319a0 100644 --- a/src/fess/test/i18n/message_keys.py +++ b/src/fess/test/i18n/message_keys.py @@ -52,3 +52,14 @@ class Messages: # action rather than merely reloading the list. SUCCESS_JOB_LOG_DELETE_ALL = "success.job_log_delete_all" SUCCESS_CRAWLING_INFO_DELETE_ALL = "success.crawling_info_delete_all" + + # ---- Admin tag (AdminTagtypeAction) ------------------------------- + # validateTagType() adds it to the name field when the name is not + # empty but TagTypeHelper.normalizeName() rejects it: blank after NFKC + # and whitespace folding (U+3000 passes @Required, whose blank check is + # String.trim()), longer than user.tag.name.max.length (default 50, the + # {0}), or holding a control/format character. + ERRORS_TAGTYPE_INVALID_NAME = "errors.tagtype_invalid_name" + # The id is derived from name + owner, so a second create of the same + # pair is refused with this global error rather than overwriting. + ERRORS_TAGTYPE_ALREADY_EXISTS = "errors.tagtype_already_exists" diff --git a/src/fess/test/ui/admin/tagtype/__init__.py b/src/fess/test/ui/admin/tagtype/__init__.py new file mode 100644 index 0000000..b96f7ca --- /dev/null +++ b/src/fess/test/ui/admin/tagtype/__init__.py @@ -0,0 +1,27 @@ +import logging + +from fess.test.ui import FessContext +from fess.test.ui.version import fess_version + +from . import add, delete, search, update, validation + +logger = logging.getLogger(__name__) + +# The first Fess with the Tag admin screen (codelibs/fess#3551). +TAG_TYPE_SINCE = (15, 9) + + +def run(context: FessContext) -> None: + version = fess_version(context) + if version < TAG_TYPE_SINCE: + logger.info(f"Fess {version[0]}.{version[1]} has no /admin/tagtype/: " + f"skipping the tagtype module") + return + # Order matters: every leaf works on the tag add creates. validation runs + # before update because its duplicate check re-creates add's exact + # name + owner pair, which update then moves to another owner. + add.run(context) + search.run(context) + validation.run(context) + update.run(context) + delete.run(context) diff --git a/src/fess/test/ui/admin/tagtype/_names.py b/src/fess/test/ui/admin/tagtype/_names.py new file mode 100644 index 0000000..4d38bb6 --- /dev/null +++ b/src/fess/test/ui/admin/tagtype/_names.py @@ -0,0 +1,30 @@ +"""Test data shared by the tagtype leaves, derived from the run's label name +so that every leaf (and a rerun of one leaf via TEST_MODULES) agrees on it.""" + +from fess.test.i18n import t +from fess.test.i18n.keys import Labels +from fess.test.ui import FessContext + +LIST_PATH = "/admin/tagtype/" +DETAILS_PATH = "/admin/tagtype/details/4/" + + +def tag_name(context: FessContext) -> str: + return f"tag{context.create_label_name()}" + + +def owner(context: FessContext) -> str: + """The owner add.py creates the tag with.""" + return f"own{context.create_label_name().lower()}" + + +def new_owner(context: FessContext) -> str: + """The owner update.py moves the tag to.""" + return f"new{context.create_label_name().lower()}" + + +def open_list(context: FessContext, page) -> None: + """Reach the tag list through the sidebar, as an admin would.""" + page.click(f"text={t(Labels.MENU_CRAWL)}") + page.click(f"text={t(Labels.MENU_TAG_TYPE)}") + page.wait_for_load_state("domcontentloaded") diff --git a/src/fess/test/ui/admin/tagtype/add.py b/src/fess/test/ui/admin/tagtype/add.py new file mode 100644 index 0000000..9e5fee6 --- /dev/null +++ b/src/fess/test/ui/admin/tagtype/add.py @@ -0,0 +1,96 @@ +import logging + +from fess.test import assert_equal, assert_true, assert_startswith +from fess.test.i18n import t +from fess.test.i18n.keys import Labels +from fess.test.ui import FessContext +from fess.test.ui.admin.tagtype._names import (DETAILS_PATH, LIST_PATH, + open_list, owner, tag_name) +from playwright.sync_api import Playwright, sync_playwright + +logger = logging.getLogger(__name__) + +PATHS = ["https://example.com/tag/a.html", "https://example.com/tag/b.html"] +VIRTUAL_HOST = "tag.example.com" + + +def setup(playwright: Playwright) -> FessContext: + context: FessContext = FessContext(playwright) + context.login() + return context + + +def destroy(context: FessContext) -> None: + context.close() + + +def run(context: FessContext) -> None: + logger.info("Starting tag add test") + + page: "Page" = context.get_admin_page() + name: str = tag_name(context) + tag_owner: str = owner(context) + logger.debug(f"Generated test tag: {name} owned by {tag_owner}") + + # Step 1: Navigate to the tag page + logger.info("Step 1: Navigating to tag page") + open_list(context, page) + assert_equal(page.url, context.url(LIST_PATH)) + + # Step 2: Open create form + logger.info("Step 2: Opening create form") + page.click(f"text={t(Labels.CRUD_LINK_CREATE)}") + assert_equal(page.url, context.url(LIST_PATH + "createnew/")) + + # Step 3: Fill form fields. permissions is left blank on purpose: the + # action then grants the tag to its owner only. + logger.info("Step 3: Filling form fields") + page.fill("input[name=\"name\"]", name) + page.fill("input[name=\"owner\"]", tag_owner) + page.fill("textarea[name=\"paths\"]", "\n".join(PATHS)) + page.fill("input[name=\"virtualHost\"]", VIRTUAL_HOST) + page.fill("input[name=\"sortOrder\"]", "1") + + # Step 4: Submit form + logger.info("Step 4: Submitting form") + page.click(f'button:has-text("{t(Labels.CRUD_BUTTON_CREATE)}")') + page.wait_for_load_state("domcontentloaded") + assert_equal(page.url, context.url(LIST_PATH)) + # The list URL is also where a rejected create re-renders the form. + assert_equal(page.locator("ul.has-error").count(), 0, + "create was rejected by validation") + + # Step 5: Verify the tag in the list, with its owner in the same row + logger.info("Step 5: Verifying tag in list") + row = page.locator(f'table tr:has-text("{name}")') + assert_equal(row.count(), 1, f"{name} is not listed exactly once") + row_text: str = row.inner_text() + assert_true(tag_owner in row_text, + f"owner {tag_owner} not in the row of {name}: {row_text}") + + # Step 6: Verify the details page + logger.info("Step 6: Verifying tag details") + page.click(f"text={name}") + page.wait_for_load_state("domcontentloaded") + assert_startswith(page.url, context.url(DETAILS_PATH)) + + assert_equal(page.input_value("input[name=\"name\"]"), name) + assert_equal(page.input_value("input[name=\"owner\"]"), tag_owner) + assert_equal(page.input_value("input[name=\"virtualHost\"]"), VIRTUAL_HOST) + assert_equal(page.input_value("input[name=\"sortOrder\"]"), "1") + + details: str = page.inner_text("section.content table") + for path in PATHS: + assert_true(path in details, f"path {path} not shown in details: {details}") + # Once in the owner row and once more in the defaulted permission. + assert_true(details.count(tag_owner) >= 2, + f"blank permissions should default to the owner {tag_owner}: {details}") + + logger.info("Tag add test completed successfully") + + +if __name__ == "__main__": + with sync_playwright() as playwright: + context: FessContext = setup(playwright) + run(context) + destroy(context) diff --git a/src/fess/test/ui/admin/tagtype/delete.py b/src/fess/test/ui/admin/tagtype/delete.py new file mode 100644 index 0000000..b631b71 --- /dev/null +++ b/src/fess/test/ui/admin/tagtype/delete.py @@ -0,0 +1,80 @@ +import logging +import time + +from fess.test import assert_equal, assert_startswith, assert_true +from fess.test.i18n import t +from fess.test.i18n.keys import Labels +from fess.test.ui import FessContext +from fess.test.ui.admin.tagtype._names import DETAILS_PATH, LIST_PATH, open_list, tag_name +from fess.test.ui.cleanup import assert_absent +from playwright.sync_api import Playwright, sync_playwright + +logger = logging.getLogger(__name__) + +# The delete is written with refresh=true, but give the list a margin +# before calling the row leaked. +CONVERGE_SECONDS = 30 + + +def setup(playwright: Playwright) -> FessContext: + context: FessContext = FessContext(playwright) + context.login() + return context + + +def destroy(context: FessContext) -> None: + context.close() + + +def run(context: FessContext) -> None: + logger.info("Starting tag delete test") + + page: "Page" = context.get_admin_page() + name: str = tag_name(context) + + # Step 1: Navigate to the tag page + logger.info("Step 1: Navigating to tag page") + open_list(context, page) + assert_equal(page.url, context.url(LIST_PATH)) + + # Step 2: Open tag details + logger.info("Step 2: Opening tag details") + page.click(f"text={name}") + page.wait_for_load_state("domcontentloaded") + assert_startswith(page.url, context.url(DETAILS_PATH)) + + # Step 3: Cancel in the confirmation dialog keeps the tag + logger.info("Step 3: Testing delete cancel button") + page.click(f'button:has-text("{t(Labels.CRUD_BUTTON_DELETE)}")') + page.click(f"text={t(Labels.CRUD_BUTTON_CANCEL)}") + assert_equal(page.input_value("input[name=\"name\"]"), name, + "cancel should leave the details page as it was") + + # Step 4: Perform delete + logger.info("Step 4: Performing delete") + page.click(f'button:has-text("{t(Labels.CRUD_BUTTON_DELETE)}")') + page.click('div.modal-footer button[name="delete"]') + page.wait_for_load_state("domcontentloaded") + assert_equal(page.url, context.url(LIST_PATH)) + assert_equal(page.locator("ul.has-error").count(), 0, + "delete was rejected") + assert_true(page.locator("div.alert-success").count() > 0, + "no success message after delete") + + # Step 5: Verify deletion, reloading until the list converges + logger.info("Step 5: Verifying deletion") + deadline = time.monotonic() + CONVERGE_SECONDS + while name in page.inner_text("section.content") and time.monotonic() < deadline: + time.sleep(1) + page.goto(context.url(LIST_PATH)) + page.wait_for_load_state("domcontentloaded") + assert_absent(page, name, LIST_PATH) + + logger.info("Tag delete test completed successfully") + + +if __name__ == "__main__": + with sync_playwright() as playwright: + context: FessContext = setup(playwright) + run(context) + destroy(context) diff --git a/src/fess/test/ui/admin/tagtype/search.py b/src/fess/test/ui/admin/tagtype/search.py new file mode 100644 index 0000000..5e26d22 --- /dev/null +++ b/src/fess/test/ui/admin/tagtype/search.py @@ -0,0 +1,110 @@ +import logging + +from fess.test import assert_equal, assert_true +from fess.test.i18n import t +from fess.test.i18n.keys import Labels +from fess.test.ui import FessContext +from fess.test.ui.admin.tagtype._names import LIST_PATH, open_list, owner, tag_name +from playwright.sync_api import Playwright, sync_playwright + +logger = logging.getLogger(__name__) + + +def setup(playwright: Playwright) -> FessContext: + context: FessContext = FessContext(playwright) + context.login() + return context + + +def destroy(context: FessContext) -> None: + context.close() + + +def _search(page, name: str = "", tag_owner: str = "") -> None: + """Fill the collapsed list search form and submit it.""" + form = page.locator("#listSearchForm") + if not form.locator("#name").is_visible(): + page.click('a[href="#listSearchForm"]') + form.locator("#name").wait_for(state="visible") + form.locator("#name").fill(name) + form.locator("#owner").fill(tag_owner) + form.locator(f'button[name="search"]:has-text("{t(Labels.CRUD_BUTTON_SEARCH)}")').click() + page.wait_for_load_state("domcontentloaded") + + +def _reset(page) -> None: + """Clear the search conditions the pager keeps in the session.""" + form = page.locator("#listSearchForm") + if not form.locator("#name").is_visible(): + page.click('a[href="#listSearchForm"]') + form.locator("#name").wait_for(state="visible") + form.locator(f'button[name="reset"]:has-text("{t(Labels.CRUD_BUTTON_RESET)}")').click() + page.wait_for_load_state("domcontentloaded") + + +def _listed(page) -> str: + return page.inner_text("section.content") + + +def run(context: FessContext) -> None: + logger.info("Starting tag list/search test") + + page: "Page" = context.get_admin_page() + name: str = tag_name(context) + tag_owner: str = owner(context) + no_match: str = f"none{context.generate_str(16)}" + + # Step 1: The unfiltered list shows the tag and both column headers + logger.info("Step 1: Checking the list page") + open_list(context, page) + assert_equal(page.url, context.url(LIST_PATH)) + headers: str = page.inner_text("table thead") + assert_true(t(Labels.TAGTYPE_NAME) in headers and t(Labels.TAGTYPE_OWNER) in headers, + f"name/owner column headers missing: {headers}") + assert_true(name in _listed(page), f"{name} not in the unfiltered list") + + try: + # Step 2: Search by name finds the tag + logger.info("Step 2: Searching by name") + _search(page, name=name) + assert_equal(page.locator(f'table tr:has-text("{name}")').count(), 1, + f"search by name did not list {name}") + + # Step 3: Search by owner finds the tag + logger.info("Step 3: Searching by owner") + _search(page, tag_owner=tag_owner) + assert_equal(page.locator(f'table tr:has-text("{name}")').count(), 1, + f"search by owner {tag_owner} did not list {name}") + + # Step 4: A name that matches nothing empties the list + logger.info("Step 4: Searching for a name that matches nothing") + _search(page, name=no_match) + listed: str = _listed(page) + assert_true(name not in listed, f"{name} listed for search {no_match}") + assert_true(t(Labels.LIST_COULD_NOT_FIND_CRUD_TABLE) in listed, + f"empty-list placeholder missing for search {no_match}: {listed}") + + # Step 5: The right name with the wrong owner matches nothing too: + # the conditions are ANDed, not ORed. + logger.info("Step 5: Searching by name with another owner") + _search(page, name=name, tag_owner=no_match) + assert_true(name not in _listed(page), + f"{name} listed although its owner is not {no_match}") + finally: + # Step 6: Reset brings the tag back and leaves no condition behind + # for the leaves that follow. + logger.info("Step 6: Resetting the search") + _reset(page) + + assert_true(name in _listed(page), f"{name} not listed after reset") + assert_equal(page.locator("#listSearchForm #name").input_value(), "", + "reset left the name condition in the form") + + logger.info("Tag list/search test completed successfully") + + +if __name__ == "__main__": + with sync_playwright() as playwright: + context: FessContext = setup(playwright) + run(context) + destroy(context) diff --git a/src/fess/test/ui/admin/tagtype/update.py b/src/fess/test/ui/admin/tagtype/update.py new file mode 100644 index 0000000..319c4be --- /dev/null +++ b/src/fess/test/ui/admin/tagtype/update.py @@ -0,0 +1,121 @@ +import logging + +from fess.test import assert_equal, assert_startswith, assert_true +from fess.test.i18n import t +from fess.test.i18n.keys import Labels +from fess.test.ui import FessContext +from fess.test.ui.admin.tagtype._names import (DETAILS_PATH, LIST_PATH, new_owner, + open_list, owner, tag_name) +from playwright.sync_api import Playwright, sync_playwright + +logger = logging.getLogger(__name__) + +UPDATED_PATH = "https://example.com/tag/updated.html" + + +def setup(playwright: Playwright) -> FessContext: + context: FessContext = FessContext(playwright) + context.login() + return context + + +def destroy(context: FessContext) -> None: + context.close() + + +def run(context: FessContext) -> None: + logger.info("Starting tag update test") + + page: "Page" = context.get_admin_page() + name: str = tag_name(context) + old_owner: str = owner(context) + moved_owner: str = new_owner(context) + + # Step 1: Navigate to the tag page + logger.info("Step 1: Navigating to tag page") + open_list(context, page) + assert_equal(page.url, context.url(LIST_PATH)) + + # Step 2: Open tag details + logger.info("Step 2: Opening tag details") + page.click(f"text={name}") + page.wait_for_load_state("domcontentloaded") + assert_startswith(page.url, context.url(DETAILS_PATH)) + old_details_url: str = page.url + + # Step 3: Edit, then Back returns to the details without saving + logger.info("Step 3: Testing edit and back button") + page.click(f"text={t(Labels.CRUD_BUTTON_EDIT)}") + page.wait_for_load_state("domcontentloaded") + assert_equal(page.input_value("input[name=\"owner\"]"), old_owner) + page.click(f"text={t(Labels.CRUD_BUTTON_BACK)}") + page.wait_for_load_state("domcontentloaded") + assert_equal(page.input_value("input[name=\"owner\"]"), old_owner) + assert_equal(page.locator("textarea[name=\"paths\"]").count(), 0, + "Back should land on the read-only details, not the edit form") + + # Step 4: Open edit form; it is pre-filled with the stored values + logger.info("Step 4: Opening edit form") + page.click(f"text={t(Labels.CRUD_BUTTON_EDIT)}") + page.wait_for_load_state("domcontentloaded") + assert_equal(page.url, context.url(LIST_PATH)) + assert_equal(page.input_value("input[name=\"name\"]"), name) + assert_true("https://example.com/tag/a.html" + in page.input_value("textarea[name=\"paths\"]"), + "edit form lost the stored paths") + permissions: str = page.input_value("textarea[name=\"permissions\"]") + assert_true(old_owner in permissions, + f"edit form permissions should hold the owner {old_owner}: {permissions}") + + # Step 5: Change the owner, the paths and the sort order. The + # permissions textarea is left as loaded: the owner's permission must + # move to the new owner with it. + logger.info("Step 5: Updating form fields") + page.fill("input[name=\"owner\"]", moved_owner) + page.fill("textarea[name=\"paths\"]", UPDATED_PATH) + page.fill("input[name=\"sortOrder\"]", "10") + + # Step 6: Submit update + logger.info("Step 6: Submitting update") + page.click(f'button:has-text("{t(Labels.CRUD_BUTTON_UPDATE)}")') + page.wait_for_load_state("domcontentloaded") + assert_equal(page.url, context.url(LIST_PATH)) + assert_equal(page.locator("ul.has-error").count(), 0, + "update was rejected by validation") + + # Step 7: The list shows the tag once, under the new owner only + logger.info("Step 7: Verifying update in list") + row = page.locator(f'table tr:has-text("{name}")') + assert_equal(row.count(), 1, f"{name} is not listed exactly once after update") + row_text: str = row.inner_text() + assert_true(moved_owner in row_text, + f"new owner {moved_owner} not in the row of {name}: {row_text}") + assert_true(old_owner not in page.inner_text("section.content"), + f"old owner {old_owner} is still listed after the owner change") + + # Step 8: The details carry the new values; the id follows the owner + logger.info("Step 8: Verifying updated details") + page.click(f"text={name}") + page.wait_for_load_state("domcontentloaded") + assert_startswith(page.url, context.url(DETAILS_PATH)) + assert_true(page.url != old_details_url, + f"the id should change with the owner, still {page.url}") + assert_equal(page.input_value("input[name=\"owner\"]"), moved_owner) + assert_equal(page.input_value("input[name=\"sortOrder\"]"), "10") + details: str = page.inner_text("section.content table") + assert_true(UPDATED_PATH in details, f"updated path not shown: {details}") + assert_true("https://example.com/tag/a.html" not in details, + f"replaced path still shown: {details}") + assert_true(details.count(moved_owner) >= 2, + f"the owner permission should move to {moved_owner}: {details}") + assert_true(old_owner not in details, + f"the old owner {old_owner} is still in the details: {details}") + + logger.info("Tag update test completed successfully") + + +if __name__ == "__main__": + with sync_playwright() as playwright: + context: FessContext = setup(playwright) + run(context) + destroy(context) diff --git a/src/fess/test/ui/admin/tagtype/validation.py b/src/fess/test/ui/admin/tagtype/validation.py new file mode 100644 index 0000000..8afa242 --- /dev/null +++ b/src/fess/test/ui/admin/tagtype/validation.py @@ -0,0 +1,130 @@ +import logging + +from fess.test import assert_equal, assert_true +from fess.test.i18n import t, tm +from fess.test.i18n.keys import Labels +from fess.test.i18n.message_keys import Messages +from fess.test.ui import FessContext +from fess.test.ui.admin.tagtype._names import LIST_PATH, open_list, owner, tag_name +from playwright.sync_api import Playwright, sync_playwright + +logger = logging.getLogger(__name__) + +# user.tag.name.max.length in the stock fess_config.properties; the {0} of +# errors.tagtype_invalid_name. +NAME_MAX_LENGTH = 50 + + +def setup(playwright: Playwright) -> FessContext: + context: FessContext = FessContext(playwright) + context.login() + return context + + +def destroy(context: FessContext) -> None: + context.close() + + +def _field_errors(page, field: str): + """The list rendered next to that input.""" + return page.locator(f'div:has(> #{field}) > ul.has-error') + + +def _submit_create(context: FessContext, page, name: str, tag_owner: str) -> None: + page.goto(context.url(LIST_PATH + "createnew/")) + page.wait_for_load_state("domcontentloaded") + page.fill("input[name=\"name\"]", name) + page.fill("input[name=\"owner\"]", tag_owner) + page.click(f'button:has-text("{t(Labels.CRUD_BUTTON_CREATE)}")') + page.wait_for_load_state("domcontentloaded") + + +def _assert_rejected_on(page, field: str, other: str, what: str) -> None: + # A rejected create re-renders the form under the list URL, so the form + # inputs still being there is what tells it from a successful redirect. + assert_true(page.locator("input[name=\"owner\"]").count() > 0, + f"{what}: the create form is gone, so it was accepted") + assert_true(_field_errors(page, field).count() > 0, + f"{what}: no error shown on {field}") + assert_equal(_field_errors(page, other).count(), 0, + f"{what}: an error was shown on {other} too") + + +def run(context: FessContext) -> None: + logger.info("Starting tag validation test") + + page: "Page" = context.get_admin_page() + name: str = tag_name(context) + tag_owner: str = owner(context) + probe_owner: str = f"val{context.generate_str(12).lower()}" + invalid_name_msg: str = tm(Messages.ERRORS_TAGTYPE_INVALID_NAME, + str(NAME_MAX_LENGTH)) + + open_list(context, page) + assert_equal(page.url, context.url(LIST_PATH)) + + # Test 1: blank name (@Required) + logger.info("Test 1: blank name") + _submit_create(context, page, "", probe_owner) + assert_equal(page.url, context.url(LIST_PATH)) + _assert_rejected_on(page, "name", "owner", "blank name") + + # Test 2: blank owner (@Required) + logger.info("Test 2: blank owner") + _submit_create(context, page, f"val{context.generate_str(12)}", "") + _assert_rejected_on(page, "owner", "name", "blank owner") + + # Test 3: a name of ASCII spaces only. @Required trims, so it rejects + # this before the tag-name check runs. + logger.info("Test 3: whitespace-only name") + _submit_create(context, page, " ", probe_owner) + _assert_rejected_on(page, "name", "owner", "whitespace-only name") + + # Test 4: a name of ideographic spaces only. String.trim() keeps + # U+3000, so @Required passes and the tag-name normalization (NFKC folds + # it to a space, then trims) is what rejects it, with its own message. + logger.info("Test 4: ideographic-space-only name") + _submit_create(context, page, "  ", probe_owner) + _assert_rejected_on(page, "name", "owner", "ideographic-space-only name") + errors: str = _field_errors(page, "name").inner_text() + assert_true(invalid_name_msg in errors, + f"expected '{invalid_name_msg}' on name, got '{errors}'") + + # Test 5: one character over user.tag.name.max.length. @Size(max=1000) + # lets it through; the tag-name check refuses it. + logger.info("Test 5: over-long name") + _submit_create(context, page, context.generate_str(NAME_MAX_LENGTH + 1), probe_owner) + _assert_rejected_on(page, "name", "owner", "over-long name") + errors = _field_errors(page, "name").inner_text() + assert_true(invalid_name_msg in errors, + f"expected '{invalid_name_msg}' on name, got '{errors}'") + + # Test 6: the same name and owner as add.py's tag. The id is derived from + # the pair, so this must be refused rather than overwrite that tag. + logger.info("Test 6: duplicate name and owner") + _submit_create(context, page, name, tag_owner) + assert_true(page.locator("input[name=\"owner\"]").count() > 0, + "duplicate tag was accepted") + already_exists: str = tm(Messages.ERRORS_TAGTYPE_ALREADY_EXISTS) + content: str = page.inner_text("section.content") + assert_true(already_exists in content, + f"expected '{already_exists}' for a duplicate, got '{content}'") + + # Nothing above was created: the list still holds add.py's tag once, + # and no tag of the probe owner. + page.goto(context.url(LIST_PATH)) + page.wait_for_load_state("domcontentloaded") + assert_equal(page.locator(f'table tr:has-text("{name}")').count(), 1, + f"{name} is not listed exactly once after the duplicate create") + listed: str = page.inner_text("section.content") + assert_true(probe_owner not in listed, + f"a rejected create left a tag of {probe_owner} behind: {listed}") + + logger.info("Tag validation test completed successfully") + + +if __name__ == "__main__": + with sync_playwright() as playwright: + context: FessContext = setup(playwright) + run(context) + destroy(context) diff --git a/src/main.py b/src/main.py index f4a3319..5a47b87 100644 --- a/src/main.py +++ b/src/main.py @@ -28,6 +28,7 @@ reqheader, scheduler, sysinfo, + tagtype, user, group, role, @@ -151,6 +152,7 @@ def get_modules_to_run() -> List[Any]: 'elevateword': elevateword, 'keymatch': keymatch, 'label': label, + 'tagtype': tagtype, 'dataconfig': dataconfig, 'pathmap': pathmap, 'relatedcontent': relatedcontent, @@ -239,7 +241,7 @@ def get_modules_to_run() -> List[Any]: # Run all modules in default order return [ accesstoken, badword, boostdoc, duplicatehost, elevateword, - keymatch, label, pathmap, relatedcontent, relatedquery, user, group, + keymatch, label, tagtype, pathmap, relatedcontent, relatedquery, user, group, role, kuromoji, protwords, mapping, stemmeroverride, stopwords, synonym, webconfig, fileconfig, dataconfig, fileauth, reqheader, scheduler, webauth, virtualhost,