diff --git a/docs/backlog/jest-cases-the-browser-suite-could-take.md b/docs/backlog/jest-cases-the-browser-suite-could-take.md index e048a091f8..c9e17c4f6e 100644 --- a/docs/backlog/jest-cases-the-browser-suite-could-take.md +++ b/docs/backlog/jest-cases-the-browser-suite-could-take.md @@ -9,8 +9,9 @@ Removing a jest case is only safe when a named e2e case asserts the same behavio assertion. Working through the suites on that rule, the cases that stay divide into a few recurring causes, and most of them are a gap in the browser suite rather than something it cannot reach. -This is the list to work from. It is deliberately not acted on in the same change as the deletions: -each item is an e2e case to write, and the jest cases it releases can then go with it. +This was the list to work from. The items marked **done** below have their browser case and their +jest cases have gone with them; what is left is what is still open, and the reasons it is open are +worth more than the list itself. ## Selectors the browser cannot reach @@ -18,25 +19,30 @@ each item is an e2e case to write, and the jest cases it releases can then go wi browser case cannot select by it. Only one of the elements these jest cases assert through is actually stuck behind that: -- `comments-counter` in `profile.spec.tsx`, asserted in four cases, is +- **done.** `comments-counter` in `profile.spec.tsx` is `
`. Its only class is hashed by the CSS modules, so nothing outside the bundle can name it. Giving it a stable class, the way `.auth-button`, `.auth-submit`, `.comment-actions` and `.sort-picker` are kept outside the modules for this reason, is what makes the counter assertable in a browser -Three others are already reachable and simply have no browser case written yet, which is a smaller +Two others are already reachable and simply have no browser case written yet, which is a smaller job than it looked: -- `spinner` renders `clsx('spinner', styles.root, …)` with `role="presentation"` -- `preloader` renders `clsx('preloader', className)` with `aria-label="Loading..."` -- `comment-actions-additional` renders `clsx('comment-actions-additional', …)`, so the order of the - admin actions can be asserted from a browser case today +- `spinner` renders `clsx('spinner', styles.root, …)` and is still open. It is the *between-pages* + indicator at `profile.tsx:162-165`, shown in place of the Load more button once a page is being + fetched, so it is reachable only when `comments` is not null. The profile's initial load and its + failure are a different element, the `Preloader` at `profile.tsx:221`, and that one is covered by + `TestProfile_LoadingAndFailureStates`; the two are easy to conflate and are not the same thing +- **done for the telegram panel.** `preloader` renders `clsx('preloader', className)`, asserted + by `TestTelegramSub_ThePanelSaysItIsWorking` +- **done.** `comment-actions-additional` carries a stable class, and the order is asserted by + `TestComment_AdminActionsKeepTheirOrder` ## Transient states nothing waits on -- buttons disabled while a vote request is in flight (`comment-votes.spec.tsx`, three cases) -- the loading indicator in the telegram subscription panel -- the spinner between pages of the profile list +- **done.** buttons disabled while a vote request is in flight, `TestVote_BothButtonsAreDisabledWhileTheVoteIsInFlight` +- **done.** the loading indicator in the telegram subscription panel +- **done.** the profile's loading and error states - the preloader that must not reappear after a load-more click Each needs a browser case that holds the request open, which the suite already knows how to do: @@ -47,23 +53,24 @@ before releasing it. The browser suite asserts what appears far more readily than what does not. Cases kept for this: -- the Reply action gone in a read-only thread. `TestComment_ReadOnlyThreadTakesTheFormAway` - asserts the comment form is gone and says nothing about the action -- Hide absent on a reader's own comment, Delete absent on another reader's -- the verification icon absent on an unverified user +- **done.** the Reply action gone in a read-only thread, now asserted in + `TestComment_ReadOnlyThreadTakesTheFormAway`, which posts a comment first so the assertion has + something to be about +- **done.** Hide absent on a reader's own comment, Delete absent on another reader's, + `TestComment_ActionsDependOnWhoseCommentItIs` +- **done.** the verification icon absent before an admin verifies - the auth dropdown starting closed ## Configurations no instance runs -- `email_notifications` and `telegram_notifications` off. The stack covers - `show_rss_subscription` and `show_email_subscription`, which are different settings +- **done.** `email_notifications` and `telegram_notifications` off, each instance being the + other's negative case - upvote-only voting, and voting hidden altogether - the controversy tooltip, which needs a comment with controversy in it ## Values inside an element, where the browser case only waits for the element -- the edit countdown's remaining seconds. `TestComment_EditWithinTheDeadline` waits for the timer - and `TestComment_EditExpiresAfterTheDeadline` waits for it to go, so a blank timer passes both +- **done.** the edit countdown's value, `TestComment_EditCountdownCountsDown` - the telegram link's full `https://t.me//?start=`, where the browser helper parses out the `start` parameter and never looks at the host or the bot name @@ -71,7 +78,8 @@ The browser suite asserts what appears far more readily than what does not. Case - the generic fallback message for an unrecognised code. The browser suite fulfils a 409 and asserts the catalogued string, which never enters that branch -- a failed check or unsubscribe being cleared by a later success, in the telegram panel +- **done.** a failed check or unsubscribe cleared by a later success, driven separately because + the two handlers clear their own error ## What is not worth moving @@ -79,3 +87,13 @@ Call counts and call arguments (`api.telegramSubscribe` called once, `getUserCom a page size) assert how a client method was used, not what a reader gets. The browser suite asserts the request and the response instead, which is the better test of the same thing, so these stay in jest or go away on their own when the code changes. + + +## What the static build would settle on its own + +Thirty-four of the cases still in jest exist because of the build rather than because of the +behaviour: eighteen assert a hashed css-module class and sixteen select by a `data-testid` the +production bundle strips. With static css there is no hash to pin and the class in the source is +the class in the browser, so those assertions have nothing left to say and the browser can select +what ships. Covering them now would mean writing browser cases whose purpose disappears with the +build, which is why they are left here rather than done. diff --git a/e2e/README.md b/e2e/README.md index 4edddbfd64..4945aafda5 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -207,9 +207,11 @@ Everything else runs in Chromium alone, for the same reason inverted: those test ## Selectors -The production bundle strips `data-testid`, so tests use what ships: the stable class hooks the widget keeps outside CSS modules (`.auth-button`, `.auth-submit`, `.comment-actions`, `.sort-picker`, `.preloader`), `title` attributes on icon-only controls, and visible text. Three shapes are worth knowing: +The production bundle strips `data-testid`, so tests use what ships: the stable class hooks the widget keeps outside CSS modules (`.auth-button`, `.auth-submit`, `.comment-actions`, `.comment-actions-additional`, `.sort-picker`, `.preloader`, `.comments-counter`), `title` attributes on icon-only controls, and visible text. These shapes are worth knowing: - `.auth` only exists while signed out, so waiting on it hangs after sign-in. `widget()` waits on the comment form, which is present either way. - The production build hashes every css-module class name to a short opaque id, so a component's own class is not something a test can hold. `role` is: the footer is `[role="contentinfo"]` and the edit countdown is `[role="timer"]`. - Comments render through an IntersectionObserver, so one below the fold is an empty `article` with no text in it. That makes any absence assertion written as a text filter pass whether the comment is gone or merely off screen; count articles instead, which is what `articleCount` is for. - Collapsing a thread hides the comment text, so a locator filtered by that text stops matching the element under test. `TestThread_CollapsePersistsAcrossReload` anchors on the comment's id instead. +- `text=Foo` matches a case-insensitive substring, so a comment whose own text contains the phrase satisfies a locator meant for the page's own copy, and two matches is a strict-mode failure. `text="Foo"` matches exactly. This surfaces late: a comment below the fold is empty under the IntersectionObserver, so the collision can pass locally and fail in CI. +- `?` is a single-character wildcard in the glob `page.Route` takes, so a pattern written with a query string never matches the request it names, the route is never intercepted, and the case passes against an unmodified response. Use a regexp for anything with a query string, as `TestProfile_LoadingAndFailureStates` does. diff --git a/e2e/comment_test.go b/e2e/comment_test.go index b3bdf8599e..b4cdeb559c 100644 --- a/e2e/comment_test.go +++ b/e2e/comment_test.go @@ -9,6 +9,8 @@ import ( neturl "net/url" "os" "path/filepath" + "regexp" + "strconv" "strings" "testing" "time" @@ -77,6 +79,10 @@ func TestComment_EditWithinTheDeadline(t *testing.T) { // the countdown only renders while the comment is still editable waitVisible(t, actions(frame, original).Locator(`[role="timer"]`)) require.NoError(t, actions(frame, original).Locator(`button:has-text("Edit")`).Click()) + // the action becomes its own way out; leaving Edit in place strands a reader in the editor + waitVisible(t, actions(frame, original).Locator(`button:has-text("Cancel")`)) + waitHidden(t, actions(frame, original).Locator(`button:has-text("Edit")`), + "the Edit action stayed beside Cancel while the comment was being edited") edited := "after edit " + runID submitForm(t, replyForm(t, frame), edited) @@ -298,6 +304,9 @@ func TestComment_AdminPinsAndVerifies(t *testing.T) { waitVisible(t, adminFrame.Locator(`[role="region"][aria-label="Pinned comments"]`)) // the verification toggle sits in the comment header beside the author, not in the action bar + // nothing marks an author verified until an admin says so, and the icon is what says it did + waitHidden(t, comment(adminFrame, text).Locator(`[title="Verified user"]`).First(), + "the author was shown as verified before anyone verified them") require.NoError(t, comment(adminFrame, text).Locator(`[title="Toggle verification"]`).First().Click()) waitVisible(t, comment(adminFrame, text).Locator(`[title="Verified user"]`).First()) @@ -445,6 +454,12 @@ func TestComment_ReadOnlyThreadTakesTheFormAway(t *testing.T) { frame := openURL(t, page, url) signInDev(t, page, frame) + // a comment to hang the Reply action on. Without one the thread is empty and an assertion that + // Reply is gone passes whether or not read-only has anything to do with it + text := "locked thread reply " + runID + postComment(t, frame, text) + waitVisible(t, actions(frame, text).Locator(`button:has-text("Reply")`)) + // the admin panel swaps its own button instead of showing the read-only notice, which is // what an ordinary reader gets require.NoError(t, frame.Locator(`button:has-text("Disable comments")`).Click()) @@ -457,10 +472,150 @@ func TestComment_ReadOnlyThreadTakesTheFormAway(t *testing.T) { require.NoError(t, err) readerFrame := reader.FrameLocator("#remark42 iframe") - waitVisible(t, readerFrame.Locator(`text=Read-only`)) + // an exact match on the status: `text=` is a case-insensitive substring, so any comment whose + // own text contains the phrase would satisfy it too, and two matches is a strict-mode failure + waitVisible(t, readerFrame.Locator(`text="Read-only"`)) waitHidden(t, readerFrame.Locator(commentFormSel).First(), "the thread is read-only but a reader is still shown a comment form") + // the form and the per-comment Reply action are separate controls, and a thread that takes one + // away has to take the other with it + waitVisible(t, comment(readerFrame, text)) + waitHidden(t, readerFrame.Locator(`button:has-text("Reply")`).First(), + "the thread is read-only but a reader is still offered Reply on a comment") require.NoError(t, frame.Locator(`button:has-text("Enable comments")`).Click()) waitVisible(t, frame.Locator(commentFormSel).First()) } + +// TestComment_AdminActionsKeepTheirOrder covers the order of the moderation actions, which nothing +// else asserts: every other case reaches one of them by name, so any arrangement passes. +// +// The order is what a moderator's hand learns, and Delete sits at the end of it deliberately. A +// reshuffle that moved Delete next to Hide would pass every other case in this file while making +// the destructive action the neighbor of a routine one. +func TestComment_AdminActionsKeepTheirOrder(t *testing.T) { + t.Parallel() + + text := "admin action order " + runID + author := newPage(t) + authorFrame := openThread(t, author) + signInAnon(t, author, authorFrame, anonName("actionorder")) + postComment(t, authorFrame, text) + + admin := newPage(t) + adminFrame := openURL(t, admin, threadURL(t)) + signInDev(t, admin, adminFrame) + + // the class is kept outside the css modules for this: the production bundle hashes the rest + // and strips the data-testid the unit suite selects by + additional := comment(adminFrame, text).Locator(".comment-actions-additional").First() + waitVisible(t, additional) + + labels, err := additional.Locator("> *").AllTextContents() + require.NoError(t, err) + require.Len(t, labels, 5, "the moderation menu no longer holds five actions: %v", labels) + + want := []string{"Hide", "Copy", "Pin", "Block", "Delete"} + for i, expected := range want { + assert.Contains(t, labels[i], expected, + "the moderation actions are out of order, wanted %v, got %v", want, labels) + } + + // the label switches, which is the render branch under test. It says nothing about the + // clipboard: copyComment sets isCopied outside its own catch, so the label appears whether the + // write succeeded or threw + require.NoError(t, additional.Locator(`button:has-text("Copy")`).Click()) + waitVisible(t, additional.Locator(`button:has-text("Copied!")`)) +} + +// TestComment_EditCountdownCountsDown covers what the countdown says, which nothing else reads. +// TestComment_EditWithinTheDeadline waits for the element and TestComment_EditExpiresAfterTheDeadline +// waits for it to go, so a timer rendering blank, showing the wrong unit, or frozen at its starting +// value passes both of them while telling the reader nothing about how long they have left. +// +// Runs against the short-edit instance so the window is small enough to watch a tick. +func TestComment_EditCountdownCountsDown(t *testing.T) { + t.Parallel() + + page := newPage(t) + frame := openURL(t, page, threadURLOn(t, shortEditURL)) + signInAnon(t, page, frame, anonName("countdown")) + + text := "countdown " + runID + postedAt := time.Now() + postComment(t, frame, text) + + timer := actions(frame, text).Locator(`[role="timer"]`) + waitVisible(t, timer) + + // the whole shape, not just the digits: trimming a suffix that is not there and parsing what + // is left accepts a bare number, and the unit is part of what the reader is being told + countdownShape := regexp.MustCompile(`^\d+s$`) + seconds := func() int { + t.Helper() + // pollText and not TextContent: this runs inside eventually, and a bare read carries + // playwright's own 30s default, which outlives the loop's budget and reports the wrong + // failure + shown, err := pollText(timer) + require.NoError(t, err) + trimmed := strings.TrimSpace(shown) + require.Truef(t, countdownShape.MatchString(trimmed), + "the countdown reads %q, which is not a number of seconds", shown) + n, convErr := strconv.Atoi(strings.TrimSuffix(trimmed, "s")) + require.NoError(t, convErr) + return n + } + + first := seconds() + // bounded from below as well as above, or a countdown starting at 2s would satisfy the shape, + // stay under the window and still decrease while telling the reader something wrong. The floor + // comes from the time actually spent since the comment was posted rather than a fixed number, + // since under -parallel 4 the setup can take seconds the scheduler decides + spent := int(time.Since(postedAt).Seconds()) + assert.GreaterOrEqual(t, first, int(editWindow.Seconds())-spent-1, + "the countdown started %ds below the edit window, having spent %ds getting there", int(editWindow.Seconds())-first, spent) + // the widget rounds the remaining time up, so a fifteen second window reads 16 at the moment + // the comment lands + assert.LessOrEqual(t, first, int(editWindow.Seconds())+1, + "the countdown starts above the edit window the instance was given") + + // it has to move, or a value hard-coded at the window would satisfy everything above + eventually(t, waitTimeout, "the countdown never decreased", func() bool { + return seconds() < first + }) +} + +// TestComment_ActionsDependOnWhoseCommentItIs covers which moderation actions an ordinary reader is +// offered, which the rest of the suite only ever exercises as an admin: TestThread_HideUserRemovesTheirCommentsOnly +// clicks Hide from a reader signed in with signInDev, and the stack gives that user ADMIN_SHARED_ID, +// so making either action admin-only would pass every other case here. +// +// One reader, two comments, so each half is the other's positive control: a rule that dropped both +// actions everywhere would satisfy an absence assertion on its own. +func TestComment_ActionsDependOnWhoseCommentItIs(t *testing.T) { + t.Parallel() + + other := newPage(t) + otherFrame := openThread(t, other) + signInAnon(t, other, otherFrame, anonName("actorsforeign")) + foreign := "foreign comment " + runID + postComment(t, otherFrame, foreign) + + reader := newPage(t) + readerFrame := openURL(t, reader, threadURL(t)) + signInAnon(t, reader, readerFrame, anonName("actorsown")) + own := "own comment " + runID + postComment(t, readerFrame, own) + + waitVisible(t, comment(readerFrame, foreign)) + + // on their own comment the reader may delete but has nobody to hide + waitVisible(t, actions(readerFrame, own).Locator(`button:has-text("Delete")`)) + waitHidden(t, actions(readerFrame, own).Locator(`button:has-text("Hide")`), + "the widget offered to hide the reader's own author") + + // on another reader's comment the reverse: hideable, not deletable + waitVisible(t, actions(readerFrame, foreign).Locator(`button:has-text("Hide")`)) + waitHidden(t, actions(readerFrame, foreign).Locator(`button:has-text("Delete")`), + "the widget offered an ordinary reader the delete action on someone else's comment") +} diff --git a/e2e/config_test.go b/e2e/config_test.go index 438dff2b2a..7d579b2845 100644 --- a/e2e/config_test.go +++ b/e2e/config_test.go @@ -102,6 +102,12 @@ func TestConfig_SubscriptionControlsCanBeHidden(t *testing.T) { rss := frame.Locator(`[title="Subscribe by RSS"]`) byMail := frame.Locator(`[title="Subscribe by Email"]`) + // this instance runs NOTIFY_USERS=email, so the telegram control has to be absent + // whatever the display settings say. The email control beside it is the positive + // control: a widget offering no subscriptions at all would satisfy the absence alone + waitHidden(t, frame.Locator(`[title="Subscribe by Telegram"]`), + "the telegram control was offered on an instance with telegram notifications off") + if tc.rss { waitVisible(t, rss) } else { diff --git a/e2e/profile_test.go b/e2e/profile_test.go index e90aedcbaa..3f1477c4de 100644 --- a/e2e/profile_test.go +++ b/e2e/profile_test.go @@ -4,6 +4,11 @@ package e2e import ( "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "sync" "testing" "github.com/mxschmitt/playwright-go" @@ -73,6 +78,16 @@ func TestProfile_ListsTheReadersOwnCommentsAndPaginates(t *testing.T) { require.NoError(t, err) require.Zero(t, absent, "the oldest comment is on the first page, so the page size is not being applied") + // the count beside the heading, which nothing else reads. The class is kept outside the css + // modules for this; the unit suite selects the same element by a data-testid the production + // bundle strips + counter := overlay.Locator(".comments-counter").First() + waitVisible(t, counter) + eventually(t, waitTimeout, "the profile never showed the number of comments the reader has", func() bool { + shown, err := counter.TextContent() + return err == nil && strings.TrimSpace(shown) == strconv.Itoa(total) + }) + loadMore := overlay.Locator(`button:has-text("Load more")`) waitVisible(t, loadMore) @@ -134,3 +149,62 @@ func TestProfile_AnonymousReaderIsNotOfferedRemoval(t *testing.T) { waitVisible(t, overlay.Locator(removal)) }) } + +// TestProfile_LoadingAndFailureStates covers the two states the profile shows instead of a list, +// which nothing else reaches: every other case answers at once and succeeds, so a profile that +// never says it is working, or that swallows a failure and shows an empty list, passes them all. +func TestProfile_LoadingAndFailureStates(t *testing.T) { + t.Parallel() + + page := newPage(t) + frame := openThread(t, page) + signInAnon(t, page, frame, anonName("profilestates")) + postComment(t, frame, "profile states "+runID) + + // held open so the loading state can be read, then failed so the error state follows + release := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + + // a regexp and not a glob: `?` is a single-character wildcard in playwright's url matching, so + // a pattern written with the query string never matches the request it names + profileList := regexp.MustCompile(`/api/v1/comments\?.*user=`) + require.NoError(t, page.Route(profileList, func(route playwright.Route) { + <-release + if err := route.Fulfill(playwright.RouteFulfillOptions{ + Status: playwright.Int(http.StatusInternalServerError), + ContentType: playwright.String("application/json"), + Body: playwright.String(`{"error":"failed"}`), + }); err != nil { + t.Errorf("fulfill profile comments failure: %v", err) + } + })) + + overlay := profileFrame(t, page, frame) + + // while the list is out: the preloader, and none of what a settled profile shows + waitVisible(t, overlay.Locator(".preloader")) + waitHidden(t, overlay.Locator(`button:has-text("Retry")`), + "the profile offered a retry before anything had failed") + waitHidden(t, overlay.Locator(".comments-counter"), + "the profile showed a comment count before the list arrived") + waitHidden(t, overlay.Locator(`button:has-text("Load more")`), + "the profile offered another page before the first had arrived") + waitHidden(t, overlay.Locator("h3.profile-title"), + "the profile showed its comments heading before the list arrived") + + unblock() + + // after it fails: the message and a way to try again, and still no count + waitVisible(t, overlay.Locator(".profile-error")) + waitVisible(t, overlay.Locator(`button:has-text("Retry")`)) + waitHidden(t, overlay.Locator(".preloader"), + "the profile was still loading after the request had failed") + waitHidden(t, overlay.Locator(".comments-counter"), + "the profile showed a comment count after the list failed to load") + waitHidden(t, overlay.Locator(`button:has-text("Load more")`), + "the profile offered another page after the list failed to load") + waitHidden(t, overlay.Locator("h3.profile-title"), + "the profile showed its comments heading after the list failed to load") +} diff --git a/e2e/subscribe_test.go b/e2e/subscribe_test.go index 84e076c49a..8f98a8a557 100644 --- a/e2e/subscribe_test.go +++ b/e2e/subscribe_test.go @@ -159,3 +159,45 @@ func TestSubscribe_PanelStaysOpenWhenAnInnerClickDetachesItsTarget(t *testing.T) // target, is not an outside click waitVisible(t, panel) } + +// TestSubscribe_BackFromTheTokenStepKeepsThePanelOpen is the flow in the widget that detaches its +// clicked control inside the click's own task, which is what the capture-phase listener exists +// for. Back on the token step changes the step synchronously, the rerender removes the Back button +// before the click reaches the document, and a bubble-phase listener would find no target inside +// the panel and close it over the email form the reader just asked for. The handler used to defer +// its step change behind a zero timeout to dodge exactly that, which is why nothing pinned it. +// +// Not parallel, for the same reason as the case above: it needs an unsubscribed dev user. +func TestSubscribe_BackFromTheTokenStepKeepsThePanelOpen(t *testing.T) { + page := newPage(t) + frame := openThread(t, page) + signInDev(t, page, frame) + + status, body := pageFetch(t, page, "DELETE", baseURL+"/api/v1/email?site=remark", nil) + require.Contains(t, []int{http.StatusOK, http.StatusBadRequest}, status, + "could not clear a subscription left by an earlier run: %s", body) + frame = reload(t, page) + + subscribe := frame.Locator(`[title="Subscribe by Email"]`) + waitVisible(t, subscribe) + require.NoError(t, subscribe.Click()) + + email := frame.Locator(`input[placeholder="Email"]`) + waitVisible(t, email) + require.NoError(t, email.Fill(fmt.Sprintf("back-%s-%d@example.com", runID, os.Getpid()))) + + // the request that moves the panel to the token step, so a submit going nowhere fails as itself + resp, err := page.ExpectResponse("**/api/v1/email/subscribe**", func() error { + return frame.Locator(`button:text-is("Submit")`).Click() + }, playwright.PageExpectResponseOptions{Timeout: playwright.Float(float64(waitTimeout.Milliseconds()))}) + require.NoError(t, err, "the panel asked the server for nothing") + require.Equal(t, http.StatusOK, resp.Status(), "the server refused to send a verification") + + back := frame.Locator(`button:text-is("Back")`) + waitVisible(t, back) + require.NoError(t, back.Click()) + + // the assertion the case exists for: the panel is still open, showing the email step again + waitVisible(t, frame.Locator(`div[role="listbox"]`)) + waitVisible(t, email) +} diff --git a/e2e/telegramsub_test.go b/e2e/telegramsub_test.go index f2c747e9eb..39edcfe313 100644 --- a/e2e/telegramsub_test.go +++ b/e2e/telegramsub_test.go @@ -5,6 +5,7 @@ package e2e import ( "net/http" "strings" + "sync" "testing" "github.com/mxschmitt/playwright-go" @@ -61,6 +62,11 @@ func TestTelegramSub_RoundTrip(t *testing.T) { clearTelegramSubscriptionSession(t, page) frame = reload(t, page) + // this instance runs NOTIFY_USERS=telegram, so the email control has to be absent. The telegram + // control opened just below is the positive control for it + waitHidden(t, frame.Locator(`[title="Subscribe by Email"]`), + "the email control was offered on an instance with email notifications off") + firstToken := openTelegramSubscription(t, frame) confirmTelegramSubscription(t, page, frame, firstToken, readerID) @@ -154,3 +160,171 @@ func confirmTelegramSubscription( require.Equal(t, http.StatusOK, resp.Status(), "the server refused a subscription token the bot confirmed") waitVisible(t, frame.Locator(`text=You have been subscribed on updates by telegram`)) } + +// TestTelegramSub_AFailedUnsubscribeIsClearedByASuccessfulOne covers the error the panel leaves +// behind, which is a path that has already regressed once: the handlers set their message on +// failure and nothing cleared it on the next success, so a reader who retried saw the success and +// the stale failure side by side. +// +// The check and the unsubscribe clear it independently, so both are driven here rather than one +// standing in for the other. The loading indicator is asserted in the same flow, since holding the +// request open is what makes it observable at all. +func TestTelegramSub_AFailedUnsubscribeIsClearedByASuccessfulOne(t *testing.T) { + page := newPage(t) + frame := openURL(t, page, threadURLOn(t, telegramURL)) + readerID := tgReaderID(t) + + loginToken := tgStartSignIn(t, frame) + tgSendToBot(t, "/start "+loginToken, readerID, "Recovery Reader") + tgConfirmSignIn(t, page, frame) + + status, body := pageFetch(t, page, http.MethodDelete, telegramURL+"/api/v1/telegram?site=remark", nil) + require.Equal(t, http.StatusOK, status, "could not clear a telegram subscription left by an earlier run: %s", body) + clearTelegramSubscriptionSession(t, page) + + frame = reload(t, page) + token := openTelegramSubscription(t, frame) + confirmTelegramSubscription(t, page, frame, token, readerID) + + clearTelegramSubscriptionSession(t, page) + frame = reload(t, page) + require.NoError(t, frame.Locator(`[title="Subscribe by Telegram"]`).Click()) + + unsubscribe := frame.Locator(`button:text-is("Unsubscribe")`) + waitVisible(t, unsubscribe) + + // first attempt refused, so the panel is holding a message + refuse := true + require.NoError(t, page.Route("**/api/v1/telegram**", func(route playwright.Route) { + if !refuse { + if err := route.Continue(); err != nil { + t.Errorf("continue telegram request: %v", err) + } + return + } + refuse = false + if err := route.Fulfill(playwright.RouteFulfillOptions{ + Status: playwright.Int(http.StatusInternalServerError), + ContentType: playwright.String("application/json"), + Body: playwright.String(`{"error":"failed"}`), + }); err != nil { + t.Errorf("fulfill telegram unsubscribe failure: %v", err) + } + })) + + require.NoError(t, unsubscribe.Click()) + errorMessage := frame.Locator(".auth-error") + waitVisible(t, errorMessage) + + // the retry is allowed through, and the message from the first attempt has to go with it + require.NoError(t, unsubscribe.Click()) + waitVisible(t, frame.Locator(`text=You have been unsubscribed by telegram to updates`)) + waitHidden(t, errorMessage, + "the panel kept the failed unsubscribe message beside the confirmation of the one that worked") +} + +// TestTelegramSub_AFailedCheckIsClearedByASuccessfulOne is the other half of the same defect. The +// check and the unsubscribe hold their own error and clear it in their own handler, so a fix to one +// says nothing about the other. +func TestTelegramSub_AFailedCheckIsClearedByASuccessfulOne(t *testing.T) { + page := newPage(t) + frame := openURL(t, page, threadURLOn(t, telegramURL)) + readerID := tgReaderID(t) + + loginToken := tgStartSignIn(t, frame) + tgSendToBot(t, "/start "+loginToken, readerID, "Check Recovery Reader") + tgConfirmSignIn(t, page, frame) + + status, body := pageFetch(t, page, http.MethodDelete, telegramURL+"/api/v1/telegram?site=remark", nil) + require.Equal(t, http.StatusOK, status, "could not clear a telegram subscription left by an earlier run: %s", body) + clearTelegramSubscriptionSession(t, page) + + frame = reload(t, page) + token := openTelegramSubscription(t, frame) + + // the reader has messaged the bot and the bot has acknowledged it, so a check would now + // succeed. The first one is refused anyway, which is what leaves the message the second clears + before := len(tgSentMessages(t)) + tgSendToBot(t, "/start "+token, readerID, "Check Recovery Reader") + tgWaitForMessage(t, before, "successfully subscribed") + + refuse := true + require.NoError(t, page.Route("**/api/v1/telegram/subscribe**", func(route playwright.Route) { + if !refuse { + if err := route.Continue(); err != nil { + t.Errorf("continue telegram check: %v", err) + } + return + } + refuse = false + if err := route.Fulfill(playwright.RouteFulfillOptions{ + Status: playwright.Int(http.StatusInternalServerError), + ContentType: playwright.String("application/json"), + Body: playwright.String(`{"error":"failed"}`), + }); err != nil { + t.Errorf("fulfill telegram check failure: %v", err) + } + })) + + check := frame.Locator(`button:text-is("Check")`) + waitVisible(t, check) + require.NoError(t, check.Click()) + + errorMessage := frame.Locator(".auth-error") + waitVisible(t, errorMessage) + + require.NoError(t, check.Click()) + waitVisible(t, frame.Locator(`text=You have been subscribed on updates by telegram`)) + waitHidden(t, errorMessage, + "the panel kept the failed check message beside the confirmation of the one that worked") +} + +// TestTelegramSub_ThePanelSaysItIsWorking covers the loading indicator, which every other case +// races past: the requests answer at once, so a panel that never shows one, or never takes it away, +// satisfies all of them. +func TestTelegramSub_ThePanelSaysItIsWorking(t *testing.T) { + page := newPage(t) + frame := openURL(t, page, threadURLOn(t, telegramURL)) + readerID := tgReaderID(t) + + loginToken := tgStartSignIn(t, frame) + tgSendToBot(t, "/start "+loginToken, readerID, "Loading Reader") + tgConfirmSignIn(t, page, frame) + + status, body := pageFetch(t, page, http.MethodDelete, telegramURL+"/api/v1/telegram?site=remark", nil) + require.Equal(t, http.StatusOK, status, "could not clear a telegram subscription left by an earlier run: %s", body) + clearTelegramSubscriptionSession(t, page) + + frame = reload(t, page) + token := openTelegramSubscription(t, frame) + + before := len(tgSentMessages(t)) + tgSendToBot(t, "/start "+token, readerID, "Loading Reader") + tgWaitForMessage(t, before, "successfully subscribed") + + // held open, or the answer lands before anything can be read + release := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + + require.NoError(t, page.Route("**/api/v1/telegram/subscribe**", func(route playwright.Route) { + <-release + if err := route.Continue(); err != nil { + t.Errorf("continue held telegram check: %v", err) + } + })) + + check := frame.Locator(`button:text-is("Check")`) + waitVisible(t, check) + require.NoError(t, check.Click()) + + preloader := frame.Locator(".preloader") + waitVisible(t, preloader) + + unblock() + waitVisible(t, frame.Locator(`text=You have been subscribed on updates by telegram`)) + // not asserted after the confirmation: the panel renders the preloader and the settled state + // from the same flag, so once either is on screen the other is necessarily gone and the + // assertion could not fail +} diff --git a/e2e/vote_test.go b/e2e/vote_test.go index 1e51b20209..f7a646427c 100644 --- a/e2e/vote_test.go +++ b/e2e/vote_test.go @@ -9,6 +9,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/mxschmitt/playwright-go" "github.com/stretchr/testify/assert" @@ -204,6 +205,11 @@ func TestVote_DownvoteAndCorrection(t *testing.T) { voter, voterFrame, target := voteScenario(t, "downvoteauthor", text) require.NoError(t, target.Locator(`button[title="Vote down"]`).Click()) + // Asserting that the button is disabled once the vote is cast looks like it belongs here and + // does not: it holds with the isDownvoted guard removed, both straight after the click, where + // the in-flight guard disables the button anyway, and after the score settles. Nothing in the + // browser separates the two, so the assertion would pass against a widget that had lost the + // rule. comment-votes.spec.tsx keeps it. eventually(t, waitTimeout, "the downvote did not register", func() bool { v, err := pollText(score(voterFrame, text)) return err == nil && v == "-1" @@ -272,3 +278,58 @@ func TestVote_WithoutTheXSRFHeaderIsRefused(t *testing.T) { return err == nil && v == "0" }) } + +// TestVote_BothButtonsAreDisabledWhileTheVoteIsInFlight covers the window between the click and the +// server's answer, which nothing else in this suite observes: the other vote cases answer at once, +// so they pass whether or not the widget guards against a second vote landing on top of the first. +// +// Both buttons are asserted, not just the one clicked. Leaving the opposite one live is the more +// interesting defect, since a reader who changes their mind mid-request sends a correction against +// a score the server has not settled yet. +func TestVote_BothButtonsAreDisabledWhileTheVoteIsInFlight(t *testing.T) { + t.Parallel() + + for _, tc := range []struct{ name, title string }{ + {"upvote", "Vote up"}, + {"downvote", "Vote down"}, + } { + t.Run(tc.name, func(t *testing.T) { + text := "vote inflight " + tc.name + " " + runID + voter, _, target := voteScenario(t, "voteinflight"+tc.name, text) + + // held open so the in-flight state can be read; an instant answer leaves nothing to see + release := make(chan struct{}) + // buffered, so the handler never blocks on a test that has already given up + continued := make(chan error, 1) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + + require.NoError(t, voter.Route("**/api/v1/vote/**", func(route playwright.Route) { + <-release + continued <- route.Continue() + })) + + require.NoError(t, target.Locator(`button[title="`+tc.title+`"]`).Click()) + + for _, title := range []string{"Vote up", "Vote down"} { + eventually(t, waitTimeout, title+" stayed live while a vote was in flight", func() bool { + disabled, err := target.Locator(`button[title="` + title + `"]`).IsDisabled() + return err == nil && disabled + }) + } + + unblock() + // Click returns while the request is still held, so cleanup can close the context + // with the handler inside Continue. Waiting on the call itself is what settles that; + // every state the widget shows is already true before the release, the optimistic + // score included, so none of them can stand in for it + select { + case err := <-continued: + require.NoError(t, err, "the held vote request could not be released") + case <-time.After(waitTimeout): + t.Fatal("the held vote request was never released") + } + }) + } +} diff --git a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx index b5e944e475..12f02e0de2 100644 --- a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx +++ b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx @@ -9,7 +9,6 @@ import { useIntl, defineMessages, FormattedMessage } from 'common/intl'; import type { User } from 'common/types'; import type { StoreState } from 'store'; import { setUserSubscribed } from 'store/user/actions'; -import { sleep } from 'utils/sleep'; import type { RequestError } from 'utils/errorUtils'; import { extractErrorMessageFromResponse } from 'utils/errorUtils'; import { useTheme } from 'hooks/useTheme'; @@ -217,8 +216,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => { const isValidEmailAddress = emailRegexp.test(emailAddress); - const setEmailStep = useCallback(async () => { - await sleep(0); + const setEmailStep = useCallback(() => { setError(null); setStep(Step.Email); }, [setStep]); diff --git a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-telegram/comment-form__subscribe-by-telegram.test.tsx b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-telegram/comment-form__subscribe-by-telegram.test.tsx index 1076bf8e62..06660166c9 100644 --- a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-telegram/comment-form__subscribe-by-telegram.test.tsx +++ b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-telegram/comment-form__subscribe-by-telegram.test.tsx @@ -91,23 +91,6 @@ describe('', () => { expect(screen.getByText(/You have been subscribed/)).toBeInTheDocument(); }); - it('should clear a failed check when a later check succeeds', async () => { - jest - .spyOn(api, 'telegramCurrentSubscribtion') - .mockRejectedValueOnce(new RequestError('failed', 500)) - .mockResolvedValueOnce({ address: '223211010', updated: true }); - - createWrapper(); - fireEvent.click(screen.getByTitle('Subscribe by Telegram')); - - fireEvent.click(await screen.findByText('Check')); - expect(await screen.findByText('Something went wrong.')).toHaveClass('auth-error'); - - fireEvent.click(screen.getByText('Check')); - expect(await screen.findByText(/You have been subscribed/)).toBeInTheDocument(); - expect(screen.queryByText('Something went wrong.')).not.toBeInTheDocument(); - }); - it('should subscribe and then unsubscribe', async () => { createWrapper(); const button = screen.getByTitle('Subscribe by Telegram'); @@ -127,24 +110,6 @@ describe('', () => { expect(screen.getByText(/You have been unsubscribed/)).toBeInTheDocument(); }); - it('should clear a failed unsubscribe when a later unsubscribe succeeds', async () => { - jest - .spyOn(api, 'telegramUnsubcribe') - .mockRejectedValueOnce(new RequestError('failed', 500)) - .mockResolvedValueOnce({ deleted: true }); - - createWrapper(); - fireEvent.click(screen.getByTitle('Subscribe by Telegram')); - fireEvent.click(await screen.findByText('Check')); - - fireEvent.click(await screen.findByText('Unsubscribe')); - expect(await screen.findByText('Something went wrong.')).toHaveClass('auth-error'); - - fireEvent.click(screen.getByText('Unsubscribe')); - expect(await screen.findByText(/You have been unsubscribed/)).toBeInTheDocument(); - expect(screen.queryByText('Something went wrong.')).not.toBeInTheDocument(); - }); - it('should subscribe, close window and then unsubscribe', async () => { createWrapper(); const button = screen.getByTitle('Subscribe by Telegram'); diff --git a/frontend/apps/remark42/app/components/comment-form/comment-form.spec.tsx b/frontend/apps/remark42/app/components/comment-form/comment-form.spec.tsx index 5d149382c1..7f8076cf7d 100644 --- a/frontend/apps/remark42/app/components/comment-form/comment-form.spec.tsx +++ b/frontend/apps/remark42/app/components/comment-form/comment-form.spec.tsx @@ -124,20 +124,11 @@ describe('', () => { expect(matchCount).toBe(2); }); }); - it('renders without email subscription button when email_notifications disabled', () => { - setup({ user }, { email_notifications: false }); - expect(screen.queryByTitle('Subscribe by Email')).not.toBeInTheDocument(); - }); it('renders Telegram subscription button', () => { setup({ user }, { telegram_notifications: true }); expect(screen.getByText(/Subscribe by/)).toBeVisible(); expect(screen.getByTitle('Subscribe by Telegram')).toBeVisible(); }); - - it('renders without Telegram subscription button if telegram_notifications is false', () => { - setup({ user }, { telegram_notifications: false }); - expect(screen.queryByTitle('Subscribe by Telegram')).not.toBeInTheDocument(); - }); }); describe('when unauthorized', () => { diff --git a/frontend/apps/remark42/app/components/comment/comment-actions.spec.tsx b/frontend/apps/remark42/app/components/comment/comment-actions.spec.tsx index fd1bdd0c61..67d9402755 100644 --- a/frontend/apps/remark42/app/components/comment/comment-actions.spec.tsx +++ b/frontend/apps/remark42/app/components/comment/comment-actions.spec.tsx @@ -3,7 +3,7 @@ import '@testing-library/jest-dom'; import type { Props } from './comment-actions'; import { CommentActions } from './comment-actions'; import { render } from 'tests/utils'; -import { fireEvent, screen, waitFor } from '@testing-library/preact'; +import { fireEvent, screen } from '@testing-library/preact'; function getProps(): Props { return { @@ -38,12 +38,6 @@ describe('', () => { jest.resetAllMocks(); }); - it('should not render "Reply" in read only mode', () => { - props.readOnly = true; - render(); - expect(screen.queryByText('Reply')).not.toBeInTheDocument(); - }); - it('should not render "Cancel" instead "Reply" in replying mode', () => { props.replying = true; render(); @@ -51,32 +45,6 @@ describe('', () => { expect(screen.getByText('Cancel')).toBeInTheDocument(); }); - it('should render "Hide" on comments not from currentUser', () => { - props.currentUser = false; - render(); - expect(screen.getByText('Hide')).toBeVisible(); - }); - - it('should not render "Hide" on comments not from currentUser', () => { - props.currentUser = true; - render(); - expect(screen.queryByText('Hide')).not.toBeInTheDocument(); - }); - - // the browser suite waits for the countdown element and then for it to go, so nothing there - // reads what it says: a blank or malformed timer passes both of those - it('renders the countdown with the remaining seconds in it', async () => { - Object.assign(props, { editable: true, editDeadline: Date.now() + 300 * 1000 }); - render(); - await waitFor(() => expect(['300s', '299s']).toContain(screen.getByRole('timer').textContent)); - }); - - it('should render "Cancel" instead "Edit" in editing mode', async () => { - Object.assign(props, { editable: true, editing: true, editDeadline: Date.now() + 300 * 1000 }); - render(); - expect(screen.getByText('Cancel')).toBeInTheDocument(); - }); - it.each([ [{ editable: false, editDeadline: Date.now() + 300 * 1000 }], [{ editable: true, editDeadline: undefined }], @@ -93,24 +61,7 @@ describe('', () => { expect(screen.queryByText('Delete')).not.toBeInTheDocument(); }); - it('should not render "Delete" for other users comments', () => { - render(); - expect(screen.queryByText('Delete')).not.toBeInTheDocument(); - }); - describe('admin actions', () => { - it('should render "Copy"', () => { - props.admin = true; - render(); - expect(screen.getByText('Copy')).toBeInTheDocument(); - }); - - it('should render "Copied" when comment copied', () => { - Object.assign(props, { admin: true, copied: true }); - render(); - expect(screen.getByText('Copied!')).toBeInTheDocument(); - }); - it.each([[{ currentUser: false, admin: true }], [{ currentUser: true, admin: true }]] as Partial[][])( 'should render "Delete" on all comments for admin', (override) => { @@ -120,16 +71,6 @@ describe('', () => { } ); - it('should render admin actions in right order', () => { - props.admin = true; - render(); - expect(screen.getByTestId('comment-actions-additional').children[0]).toHaveTextContent('Hide'); - expect(screen.getByTestId('comment-actions-additional').children[1]).toHaveTextContent('Copy'); - expect(screen.getByTestId('comment-actions-additional').children[2]).toHaveTextContent('Pin'); - expect(screen.getByTestId('comment-actions-additional').children[3]).toHaveTextContent('Block'); - expect(screen.getByTestId('comment-actions-additional').children[4]).toHaveTextContent('Delete'); - }); - it('calls `onToggleEditing` when edit button is pressed', () => { props.editable = true; props.editDeadline = Date.now() + 300 * 1000; diff --git a/frontend/apps/remark42/app/components/comment/comment-votes.spec.tsx b/frontend/apps/remark42/app/components/comment/comment-votes.spec.tsx index a4d1236338..63217f9c0d 100644 --- a/frontend/apps/remark42/app/components/comment/comment-votes.spec.tsx +++ b/frontend/apps/remark42/app/components/comment/comment-votes.spec.tsx @@ -8,27 +8,11 @@ import { CommentVotes } from './comment-votes'; import { StaticStore } from 'common/static-store'; describe('', () => { - it('should disable buttons after upvote when request is in progress', () => { - jest.spyOn(api, 'putCommentVote').mockImplementationOnce(jest.fn(() => new Promise(() => {}))); - render(); - fireEvent(screen.getByTitle('Vote up'), new Event('click')); - expect(screen.getByTitle('Vote down')).toBeDisabled(); - expect(screen.getByTitle('Vote up')).toBeDisabled(); - }); - it('should disable downvote button when downvoted', () => { render(); expect(screen.getByTitle('Vote down')).toBeDisabled(); }); - it('should disable buttons after downvote when request is in progress', async () => { - jest.spyOn(api, 'putCommentVote').mockImplementationOnce(jest.fn(() => new Promise(() => {}))); - render(); - fireEvent(screen.getByTitle('Vote down'), new Event('click')); - expect(screen.getByTitle('Vote down')).toBeDisabled(); - expect(screen.getByTitle('Vote up')).toBeDisabled(); - }); - it.each([ ['upvote', 1, 'Vote up', 'Vote down', 'upVoteButtonActive'], ['downvote', -1, 'Vote down', 'Vote up', 'downVoteButtonActive'], diff --git a/frontend/apps/remark42/app/components/comment/comment.test.tsx b/frontend/apps/remark42/app/components/comment/comment.test.tsx index 1c58b9e88a..085374e973 100644 --- a/frontend/apps/remark42/app/components/comment/comment.test.tsx +++ b/frontend/apps/remark42/app/components/comment/comment.test.tsx @@ -80,14 +80,6 @@ describe('', () => { expect(patreonSubscriberIcon.tagName).toBe('IMG'); }); - describe('verification', () => { - it('should not render verification icon', () => { - const props = getProps(); - render(); - expect(screen.queryByTitle('Verified user')).not.toBeInTheDocument(); - }); - }); - describe('voting', () => { let props = getProps(); diff --git a/frontend/apps/remark42/app/components/profile/components/counter/counter.tsx b/frontend/apps/remark42/app/components/profile/components/counter/counter.tsx index f45d589483..60c50b91f8 100644 --- a/frontend/apps/remark42/app/components/profile/components/counter/counter.tsx +++ b/frontend/apps/remark42/app/components/profile/components/counter/counter.tsx @@ -1,9 +1,13 @@ import { h, type FunctionComponent } from 'preact'; +import clsx from 'clsx'; + import styles from './counter.module.css'; export const Counter: FunctionComponent = ({ children }) => { return ( -
+ // the class is kept outside the css modules so the browser suite can read the count: the + // production bundle hashes the module names and strips the data-testid beside it +
{children}
); diff --git a/frontend/apps/remark42/app/components/profile/profile.spec.tsx b/frontend/apps/remark42/app/components/profile/profile.spec.tsx index 15c924363e..7299460233 100644 --- a/frontend/apps/remark42/app/components/profile/profile.spec.tsx +++ b/frontend/apps/remark42/app/components/profile/profile.spec.tsx @@ -40,33 +40,6 @@ const commentStub: Comment = { const commentsStub = [commentStub, commentStub, commentStub]; describe('', () => { - it('should render preloader', () => { - jest.spyOn(pq, 'parseQuery').mockImplementation(() => ({ ...userParamsStub })); - const { queryByLabelText, queryByRole, queryByTestId } = render(); - - expect(queryByLabelText('Loading...')).toBeInTheDocument(); - expect(queryByRole('button', { name: /retry/i })).not.toBeInTheDocument(); - expect(queryByRole('heading', { name: /my comments/i })).not.toBeInTheDocument(); - expect(queryByRole('heading', { name: /comments/i })).not.toBeInTheDocument(); - expect(queryByTestId('comments-counter')).not.toBeInTheDocument(); - expect(queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); - }); - - it('should render error', async () => { - jest.spyOn(pq, 'parseQuery').mockImplementation(() => ({ ...userParamsStub })); - jest.spyOn(api, 'getUserComments').mockImplementation(() => { - throw new Error('error'); - }); - const { queryByLabelText, queryByRole, findByRole, queryByTestId } = render(); - - expect(await findByRole('button', { name: /retry/i })).toBeInTheDocument(); - expect(queryByLabelText('Loading...')).not.toBeInTheDocument(); - expect(queryByRole('heading', { name: /my comments/i })).not.toBeInTheDocument(); - expect(queryByRole('heading', { name: /comments/i })).not.toBeInTheDocument(); - expect(queryByTestId('comments-counter')).not.toBeInTheDocument(); - expect(queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); - }); - it('should render user without comments', async () => { jest.spyOn(pq, 'parseQuery').mockImplementation(() => ({ ...userParamsStub })); const getUserComments = jest