Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/fess/test/i18n/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"

Expand Down
11 changes: 11 additions & 0 deletions src/fess/test/i18n/message_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
27 changes: 27 additions & 0 deletions src/fess/test/ui/admin/tagtype/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
30 changes: 30 additions & 0 deletions src/fess/test/ui/admin/tagtype/_names.py
Original file line number Diff line number Diff line change
@@ -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")
96 changes: 96 additions & 0 deletions src/fess/test/ui/admin/tagtype/add.py
Original file line number Diff line number Diff line change
@@ -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)
80 changes: 80 additions & 0 deletions src/fess/test/ui/admin/tagtype/delete.py
Original file line number Diff line number Diff line change
@@ -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)
110 changes: 110 additions & 0 deletions src/fess/test/ui/admin/tagtype/search.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading