diff --git a/phpbb-test-board/README.md b/phpbb-test-board/README.md index 6365b37..e57c2b9 100644 --- a/phpbb-test-board/README.md +++ b/phpbb-test-board/README.md @@ -148,6 +148,8 @@ Saves PNG screenshots of an extension's or style's pages with [Playwright](https `phpbbmodders/copylinks` clicks a topic title, a post subject and an FAQ question and shows each one's icon turned into a tick, with the copy to the clipboard stubbed, since headless Chromium may not write to it. It also saves the ACP settings. Use `--seed` with seed-forum's standard fixtures for a topic with replies. +`phpbbmodders/seenotes` needs `--seed` with seed-forum's standard fixtures; it then runs seed-forum's `bin/seed-seenotes-fixtures.php` from the same folder, which gives the members reasons, notes and warnings. Reasons show only to staff, so every shot is taken as the admin: a post's mini-profile with the reason, a profile with a reason and one with NONE, the MCP post details notice, MCP → Moderator notes, ACP → Manage users → Moderator notes, and the ACP reasons and settings pages (in full). + `phpbbmodders/topicclaim` needs `--seed` with seed-forum's standard fixtures, for its `forum_moderator` user. That user claims the busiest topic with a note, and the admin claims another topic in the same forum. Claims show only to moderators, so every shot is taken as the admin: the forum's topic list, the claim notice on the topic page, the claim page (with take over, transfer and release), MCP → Claimed topics and the ACP settings. `phpbbmodders/printallposts` sets the board to two posts per page, so the busiest topic spans two pages, then saves the **Topic tools** menu open on it and the **Print all** page showing every post, both as a guest. Use `--seed` with seed-forum's standard fixtures. diff --git a/phpbb-test-board/feature_checks.py b/phpbb-test-board/feature_checks.py index e7089a0..fcb9401 100755 --- a/phpbb-test-board/feature_checks.py +++ b/phpbb-test-board/feature_checks.py @@ -1191,7 +1191,143 @@ def external_stub(route) -> None: ok("elonw.js" not in admin.get(topic_url, timeout=20).text, "turning it off removes the script") +def check_seenotes(b: Board) -> None: + """Set up a reason, give it to a member with BBCode notes, and check who sees the reason line where.""" + poster = b.sql("SELECT p.post_id, p.topic_id, p.poster_id, u.username FROM phpbb_posts p " + "JOIN phpbb_users u ON u.user_id = p.poster_id WHERE p.poster_id NOT IN (1, 2) " + "AND p.post_visibility = 1 ORDER BY p.post_id") + ok(poster, "the board has a post by a member other than the admin") + if not poster: + return + post_id, topic_id, member_id, member_name = poster[0] + starters = [row[0] for row in b.sql("SELECT reason_title FROM phpbb_seenotes_reasons ORDER BY reason_order")] + ok(starters[:7] == ["Moderation queue", "Spam watch", "Warez", "Conduct", "Possible returning member", + "Staff in contact", "General"], "enabling the extension adds the seven starter reasons") + admin = requests.Session() + sid = b.login(admin, acp=True) + acp = f"{b.base}/adm/index.php?i=-phpbbmodders-seenotes-acp-main_module&sid={sid}" + + # ACP: settings, then a reason; a missing description is refused while required + page_url = f"{acp}&mode=settings" + action, fields = form_fields(admin.get(page_url, timeout=20).text, "acp_seenotes_settings") + post_back(admin, f"{b.base}/adm", page_url, action, {**fields, "seenotes_label": "STAFF NOTE", + "seenotes_require_description": "1", "seenotes_allow_hard_delete": "1", "submit": "Submit"}) + saved = dict(b.sql("SELECT config_name, config_value FROM phpbb_config WHERE config_name LIKE 'seenotes_%'")) + ok(saved.get("seenotes_label") == "STAFF NOTE" and saved.get("seenotes_allow_hard_delete") == "1", + "the ACP settings are saved") + title = "Queue " + secrets.token_hex(3) + page_url = f"{acp}&mode=reasons&action=add" + action, fields = form_fields(admin.get(page_url, timeout=20).text, "acp_seenotes_reason") + body = post_back(admin, f"{b.base}/adm", page_url, action, {**fields, "reason_title": title, + "reason_description": "", "reason_color": "#0055aa", "submit": "Submit"}) + ok("Enter a description" in body and not b.sql("SELECT 1 FROM phpbb_seenotes_reasons WHERE reason_title = ?", + (title,)), "a reason without the required description is refused") + action, fields = form_fields(admin.get(page_url, timeout=20).text, "acp_seenotes_reason") + post_back(admin, f"{b.base}/adm", page_url, action, {**fields, "reason_title": title, + "reason_description": "Approve first", "reason_color": "#0055aa", "submit": "Submit"}) + reason = b.sql("SELECT reason_id, reason_color FROM phpbb_seenotes_reasons WHERE reason_title = ?", (title,)) + ok(reason and reason[0][1] == "0055AA", "the ACP adds a reason with its color") + if not reason: + return + reason_id = reason[0][0] + + # MCP: give the member the reason, then write a note with a quote + mcp = (f"{b.base}/mcp.php?i=-phpbbmodders-seenotes-mcp-main_module&mode=notes&u={member_id}&sid={sid}") + action, fields = form_fields(admin.get(mcp, timeout=20).text, "seenotes-add-entry") + post_back(admin, b.base, mcp, action, {**fields, "reason_id": str(reason_id), "entry_current": "1"}) + entry = b.sql("SELECT entry_id, entry_current FROM phpbb_seenotes_entries WHERE user_id = ? AND reason_id = ?", + (member_id, reason_id)) + ok(entry and str(entry[0][1]) == "1", "the MCP gives the member the reason as current") + if not entry: + return + entry_id = entry[0][0] + page = admin.get(mcp, timeout=20).text + ok('id="message"' in page and "bbstyle(" in page, "the note form has the BBCode buttons") + action, fields = form_fields(page, "postform") + note = '[quote="admin"]Please stop double posting.[/quote]Sent as a PM.' + post_back(admin, b.base, mcp, action, {**fields, "entry_id": str(entry_id), "message": note, + "submit_note": "Save note"}) + stored = b.sql("SELECT note_id, note_text FROM phpbb_seenotes_notes WHERE entry_id = ?", (entry_id,)) + ok(stored and " Manage users shows the same records + users = f"{b.base}/adm/index.php?i=users&mode=seenotes&u={member_id}&sid={sid}" + html = admin.get(users, timeout=20).text + ok(title in html and "Please stop double posting." in html, "ACP Manage users shows the reasons and notes") + + # Delete the note through the confirm box, then log entries + delete_url = f"{mcp}&action=delete_note¬e_id={note_id}" + action, fields = form_fields(admin.get(delete_url, timeout=20).text, "confirm") + post_back(admin, b.base, delete_url, action, {**fields, "confirm": "Yes"}) + ok(not b.sql("SELECT 1 FROM phpbb_seenotes_notes WHERE note_id = ?", (note_id,)), + "deleting a note asks to confirm, then deletes it") + logged = {row[0] for row in b.sql("SELECT log_operation FROM phpbb_log WHERE log_operation LIKE 'LOG_SEENOTES_%'")} + ok({"LOG_SEENOTES_ENTRY_ADDED", "LOG_SEENOTES_NOTE_ADDED", "LOG_SEENOTES_NOTE_EDITED", + "LOG_SEENOTES_NOTE_DELETED", "LOG_SEENOTES_REASON_ADDED"} <= logged, "every change is logged") + + # Deleting a member deletes the records about them + b.php(f"$e = $phpbb_container->get('phpbbmodders.seenotes.manager')->add_entry({viewer_id}, {reason_id}, true);" + f"user_delete('remove', {viewer_id});", check=True) + ok(not b.sql("SELECT 1 FROM phpbb_seenotes_entries WHERE user_id = ?", (viewer_id,)), + "deleting a member deletes their entries") + + # Hard delete: two screens, the reason's name typed; a wrong name deletes nothing + page_url = f"{acp}&mode=reasons&action=delete&id={reason_id}" + + def delete_with_name(name: str) -> str: + """Go through both delete screens, typing a name on the second; return the second screen.""" + action, fields = form_fields(admin.get(page_url, timeout=20).text, "acp_seenotes_delete") + step2 = post_back(admin, f"{b.base}/adm", page_url, action, {**fields, "submit": "Continue"}) + action, fields = form_fields(step2, "acp_seenotes_delete") + post_back(admin, f"{b.base}/adm", page_url, action, {**fields, "confirm_name": name, "submit": "Delete"}) + return step2 + + ok('name="confirm_name"' in delete_with_name("wrong"), "the second delete screen asks for the reason's name") + ok(b.sql("SELECT 1 FROM phpbb_seenotes_reasons WHERE reason_id = ?", (reason_id,)), + "a wrong name deletes nothing") + delete_with_name(title) + ok(not b.sql("SELECT 1 FROM phpbb_seenotes_reasons WHERE reason_id = ?", (reason_id,)) + and not b.sql("SELECT 1 FROM phpbb_seenotes_entries WHERE reason_id = ?", (reason_id,)), + "typing the name deletes the reason with its entries") + + # Without a current reason, staff see NONE linking to the form that gives one + html = admin.get(pages["the profile"], timeout=20).text + ok(re.search(r']*>NONE' % member_id, html), + "a member without a current reason shows NONE, linking to the Give a reason form") + + CHECKS = { + "phpbbmodders/seenotes": check_seenotes, "phpbbmodders/autodbbackup": check_autodbbackup, "phpbbmodders/elonw": check_elonw, "phpbbmodders/genders": check_genders, diff --git a/phpbb-test-board/screenshots.py b/phpbb-test-board/screenshots.py index d6a242b..0cfa5e0 100755 --- a/phpbb-test-board/screenshots.py +++ b/phpbb-test-board/screenshots.py @@ -1270,7 +1270,46 @@ def public_directory(p) -> None: shoot.shot("autodbbackup-update-directory-error", acp, admin=True, full_page=True, prepare=public_directory) +def shots_seenotes(board: Board, shoot: Shooter, args: argparse.Namespace) -> None: + """phpbbmodders/seenotes: the reason line next to a post and on a profile, NONE for a member + without a current reason, the MCP post notice, MCP → Moderator notes, and the ACP pages. + + Needs seed-forum's fixtures: run with --seed and seed-forum's + bin/seed-standard-fixtures.php; seed-forum's bin/seed-seenotes-fixtures.php, + next to it, then gives the members reasons, notes and warnings. Reasons are + only shown to staff, so every shot is taken as the admin. + """ + if not args.seed: + raise RuntimeError("run with --seed and seed-forum's bin/seed-standard-fixtures.php") + fixtures = Path(args.seed).resolve().parent / "seed-seenotes-fixtures.php" + seeded = subprocess.run(["php", "-d", "opcache.enable_cli=0", str(fixtures), str(board.root)], + capture_output=True, text=True) + if seeded.returncode != 0: + raise RuntimeError(f"See Notes fixtures failed: {(seeded.stderr or seeded.stdout).strip()[-300:]}") + board.purge_cache() + + alex = board.sql("SELECT user_id FROM phpbb_users WHERE username = 'forum_alex'")[0][0] + drew = board.sql("SELECT user_id FROM phpbb_users WHERE username = 'forum_drew'")[0][0] + post = board.sql("SELECT post_id FROM phpbb_posts WHERE poster_id = ? ORDER BY post_time DESC LIMIT 1", (alex,))[0][0] + topic = board.sql("SELECT topic_id FROM phpbb_posts WHERE post_id = ?", (post,))[0][0] + mcp = "mcp.php?i=-phpbbmodders-seenotes-mcp-main_module&mode=notes&u={user}&sid={{sid}}" + acp = "adm/index.php?i=-phpbbmodders-seenotes-acp-main_module&mode={mode}&sid={{sid}}" + shoot.shot("seenotes-viewtopic", f"viewtopic.php?t={topic}&sid={{sid}}", admin=True, selector=f"#p{post}") + shoot.shot("seenotes-profile", f"memberlist.php?mode=viewprofile&u={alex}&sid={{sid}}", admin=True, + selector="form#viewprofile") + shoot.shot("seenotes-profile-none", f"memberlist.php?mode=viewprofile&u={drew}&sid={{sid}}", admin=True, + selector="form#viewprofile") + shoot.shot("seenotes-mcp-notes", mcp.format(user=alex), admin=True, selector="#page-body") + shoot.shot("seenotes-mcp-post", f"mcp.php?i=main&mode=post_details&p={post}&sid={{sid}}", admin=True, + selector="#page-body") + shoot.shot("seenotes-acp-reasons", acp.format(mode="reasons"), admin=True, full_page=True) + shoot.shot("seenotes-acp-settings", acp.format(mode="settings"), admin=True, full_page=True) + shoot.shot("seenotes-acp-users", f"adm/index.php?i=users&mode=seenotes&u={alex}&sid={{sid}}", admin=True, + full_page=True) + + SHOTS = { + "phpbbmodders/seenotes": shots_seenotes, "phpbbmodders/adduser": shots_adduser, "phpbbmodders/autodbbackup": shots_autodbbackup, "phpbbmodders/contactadmin": shots_contactadmin,