Feature: The multi-user flows hold up under concurrency, failures and edge input As the maintainer of RenewCMS I want the invitation, account-menu and password-reset flows to be safe under concurrent use, server failures and unusual input So that a closed registration cannot be bypassed, no address is revealed, and nobody is left with a spinner or a lost focus Background: Given this pass adds no endpoint, no route, no response shape and no design change And every item below is covered by a test that fails before the change and passes after it, or is stated as not testable And error responses are plain text from http.Error, success responses are JSON And the layering rule holds: domain has no GORM or HTTP, use cases see only domain interfaces, handlers see only use cases, and no comment is added anywhere Rule: Accepting an invite is atomic Background: Given the persistence tests run against a real in-memory SQLite database migrated with the same models as api/dependecyInjection.go, not a fake repository And marking an invite accepted is a conditional update on the invite row with accepted_at IS NULL, and the number of rows affected is checked And the conditional update and the user insert run in one database transaction, so both commit or neither does And no domain, use case or handler code knows about the transaction: the use case calls a domain repository method and the persistence implementation owns the transaction Scenario: Two concurrent accepts of one code create exactly one user Given a pending invite for "lea.martin@renew.example" with raw code "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42" When two clients at the same moment send POST /v1/auth/invite/accept with that code, one with username "lea" and one with username "lea2", both with password "limewash2026" Then exactly one response is 200 with a jwt cookie And the other response is 404 with body "This invite link doesn't work." and no jwt cookie And the users table holds exactly one user with email "lea.martin@renew.example" And the invite has accepted_at set Scenario: The concurrency test is repeatable Given the two-accept scenario above When it runs 50 times, each on a fresh database Then every run ends with exactly one user and one 200 Scenario: A used code loses at the conditional update, not at the earlier lookup Given a pending invite that another accept marks accepted after this accept has read it but before it marks it When this accept marks the invite Then zero rows are affected and the accept fails with the same invite-not-found error a used code gives And the response is 404 with body "This invite link doesn't work." And no user was inserted for this accept Scenario: A failed user insert leaves the invite pending Given a pending invite for "lea.martin@renew.example" And a verified user "marta" When an accept with username "marta" is sent Then the response is 409 with body "That username is taken. Try another." And the invite is still pending, with accepted_at empty Scenario: A failed invite update leaves no user behind Given a pending invite for "lea.martin@renew.example" And the conditional update fails after the user insert has been attempted in the transaction When the accept runs Then the transaction rolls back and no user "lea" exists And the invite is still pending And the response is 500 and no jwt cookie is set Scenario: The existing accept behaviour is unchanged Given the scenarios of features/accept_invitation.feature Then all of them still pass, except that a failure marking the invite accepted no longer makes the use case call userRepository.Delete: the rollback replaces the compensating delete Rule: The accept screen recovers from a failed invite lookup Background: Given the route /invite/accept?c=CODE of docs/design/screens/accept-invite.md And the design has a "Server error" state for the submit only, so the failed lookup is a new state approved by the user, with no board frame Scenario Outline: A failed lookup shows a server-error alert with a Try again action Given the SPA is at /invite/accept?c=abc123 When GET /v1/auth/invite?c=abc123 Then the "Checking your invite…" spinner is replaced by an Alert (error, circle-x) with title "Couldn't check your invite" and text "Something went wrong on the server. Try again in a moment." And a "Try again" button is visible And no form, no Email field and no ResultPanel is shown And the spinner is not shown Examples: | outcome | | answers 500 | | answers 502 | | fails with a network error | Scenario: Try again looks the invite up again Given the failed-lookup alert is shown When the person activates "Try again" Then the "Checking your invite…" spinner is shown and GET /v1/auth/invite?c=abc123 is sent again And when it answers 200 {"email": "lea.martin@renew.example"} the Default state is shown Scenario: Try again failing again shows the alert again Given the failed-lookup alert is shown When the person activates "Try again" and the lookup fails again Then the alert is shown again and the button is enabled Scenario Outline: Other lookup outcomes keep their design states When GET /v1/auth/invite?c=abc123 answers Then the design state "" is shown Examples: | status | state | | 404 | Invalid or used link (404) | | 410 | Expired link (410) | Rule: The reset-request 200 stays generic when the mail fails Scenario: A failed send is logged and the response is unchanged Given the user repository contains a user "alice" with email "alice@example.com" And the mail repository is set to fail on Send with the error "smtp: connection refused" When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"} Then the response status is 200 And the response body is {"message": "If an account exists for that email, a password reset link has been sent."} And no jwt cookie is set And the process log contains a line naming the failed send and the error "smtp: connection refused" Scenario: The response is identical for a failed send, a successful send and an unknown email Given three requests: alice with a failing mailer, alice with a working mailer, and an unknown address Then the status, the body and the response headers other than Date are the same in all three Scenario: The log does not contain the raw reset code Given the same failing send Then the process log contains neither the raw code nor its hash Rule: The invite email is validated identically in the SPA and the Go validator Background: Given the Go rule is the validate tag of the invite request struct checked with validator.Validate.Struct, extended through validator.InitValidator, not by hand And the SPA rule is the InviteUserModal pattern And the accepted and rejected addresses are the single table below And user@localhost is rejected in both Scenario Outline: The same address gets the same verdict on both sides When POST /v1/user/invite is sent with body {"email": ""} by a verified user And the same address is typed in the invite modal Then the API answers And the modal shows "" Examples: | email | status | modal | | sam.oliveira@renew.example | 201 | no error | | Sam.Oliveira@Renew.example | 201 | no error | | sam+news@renew.example | 201 | no error | | sam@mail.renew.example | 201 | no error | | user@localhost | 400 | Enter a full email address, like name@example.com. | | sam@renew | 400 | Enter a full email address, like name@example.com. | | sam@.renew.example | 400 | Enter a full email address, like name@example.com. | | sam@renew..example | 400 | Enter a full email address, like name@example.com. | | sam@renew.example. | 400 | Enter a full email address, like name@example.com. | | sam @renew.example | 400 | Enter a full email address, like name@example.com. | | @renew.example | 400 | Enter a full email address, like name@example.com. | | not-an-email | 400 | Enter a full email address, like name@example.com. | | | 400 | Enter a full email address, like name@example.com. | Scenario: A rejected address stores nothing and sends nothing When POST /v1/user/invite is sent with body {"email": "user@localhost"} Then no invite was created and no email was sent Rule: The mobile account sheet keeps its chip state and focus Background: Given the account chip (aria-haspopup "menu", title "Account") of docs/design/components.md and the account menu of docs/design/screens/profile.md, shown as a sheet on mobile (390 px) And there is no visual change: the sand fill while open is unchanged Scenario Outline: The chip's aria-expanded tracks the sheet Given the chip is visible and the sheet is closed Then the chip has aria-expanded "false" When the person activates the chip Then the sheet is open and the chip has aria-expanded "true" When the sheet closes by Then the chip has aria-expanded "false" Examples: | closing action | | pressing Escape | | tapping the backdrop | | activating "Profile" | Scenario Outline: Focus returns to the opener when the sheet closes Given the sheet was opened from the chip When the sheet closes by Then focus returns to the chip if it is still mounted, otherwise to the navbar toggle Examples: | closing action | | pressing Escape | | tapping the backdrop | | activating "Profile" | Scenario: Logging out does not try to focus a removed chip Given the sheet is open When the person activates "Log out" Then the session ends and no error is thrown Scenario: Focus is not stolen when the sheet was never opened Given the page has just loaded Then focus is not on the chip Rule: userRepository.Delete deletes through the persistence model Scenario: Deleting a user removes only that row Given a real in-memory SQLite database with users 1, 2 and 3 When Delete is called for user 2 Then user 2 is gone and users 1 and 3 remain And Delete goes through the persistence model, not the domain struct