158 lines
13 KiB
Gherkin
158 lines
13 KiB
Gherkin
Feature: Role 409 conflicts: stale save, change role, transfer ownership and a missing reassign target (group 15)
|
|
|
|
As a person editing a role, changing a member's role or handing over ownership
|
|
I want a stale request to say who or what changed, to keep what I entered and to offer the next step
|
|
So that nothing is overwritten and I never lose my edits
|
|
|
|
Background:
|
|
Given the spec of record is docs/design/screens/roles.md groups 12, 13 and 15, the table "HTTP status to copy" rows role_conflict, and docs/design/states.md "Stale state"
|
|
And the backend contract is, after this slice:
|
|
| call | status | body |
|
|
| PUT /v1/role/{id} with a stale version | 409 | role_conflict |
|
|
| PUT /v1/user/{id}/role | 409 | user_role_changed |
|
|
| POST /v1/site/transfer-ownership | 409 | user_role_changed |
|
|
| DELETE /v1/user/{id} | 409 | user_role_changed |
|
|
| DELETE /v1/role/{id} while it has members | 409 | role_has_members |
|
|
| POST /v1/role/{id}/reassign, target missing | 409 | target_role_not_found |
|
|
| POST /v1/role/{id}/reassign, same role, locked or uncovered target | 400 | invalid_reassign |
|
|
| any action on a deleted role (the path role) | 404 | role_not_found |
|
|
And a 409 body is plain text and names nobody: the SPA re-reads GET /v1/role/{id} (updated_by, updated_at) for a stale role save, and GET /v1/user for the member's current role
|
|
And the removal route and its 409 are specified in features/roles_sync_remove_user.feature
|
|
And the message appears where the person is working (banner in the editor, alert in a dialog) and never as a toast while that surface is open; a toast shows only when the surface is gone
|
|
And a warning toast stays until dismissed or Reload is pressed; an info toast closes after 6 s
|
|
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
|
|
|
|
Rule: Backend: a missing reassign target is a distinct 409
|
|
|
|
Background for this Rule (house style of roles_permissions_roles_api.feature):
|
|
Given the new domain sentinel role.ErrReassignTargetNotFound and the new apperr string ReassignTargetNotFoundErrorMessage = "target_role_not_found"
|
|
And ReassignRoleMembersUseCase.checkTarget returns it when roles.Get(target) answers role.ErrRoleNotFound, instead of role.ErrInvalidReassign
|
|
And api/handlers/role maps it to 409 with the plain-text body, in the existing refusals table
|
|
And target 0, target equal to the source, a locked target and a target the caller cannot cover still answer 400 invalid_reassign
|
|
And a missing source role still answers 404 role_not_found
|
|
And nothing is moved and no event is recorded on this refusal (a refusal records nothing)
|
|
And docs/api.md documents the 409 on POST /v1/role/{id}/reassign
|
|
|
|
Scenario Outline: Reassign target answers
|
|
When the Owner sends POST /v1/role/<source>/reassign {"target_role_id": <target>}
|
|
Then the answer is <status> <body>
|
|
And no member moved
|
|
Examples:
|
|
| source | target | status | body |
|
|
| Contributor | an id that no longer exists | 409 | target_role_not_found |
|
|
| Contributor | Contributor (itself) | 400 | invalid_reassign |
|
|
| Contributor | 0 | 400 | invalid_reassign |
|
|
| Contributor | Owner (locked) | 400 | invalid_reassign |
|
|
| Contributor | Admin, caller is a People manager | 400 | invalid_reassign |
|
|
| 9999 | Writer | 404 | role_not_found |
|
|
|
|
Rule: Stale role save (editor)
|
|
|
|
Scenario: The banner
|
|
Given Contributor was loaded at version 2 and the person made 2 edits
|
|
When PUT /v1/role/4 answers 409 role_conflict and GET /v1/role/4 answers updated_by "Jonas Weber", updated_at 11:02
|
|
Then a banner above the form reads "This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits." with "Reload role" and "Review differences"
|
|
And the edits are kept, Save is off, and the bar reads "2 unsaved changes. Save is off until you reload"
|
|
|
|
Scenario: Review differences
|
|
Given the banner is shown with edits Events Update Own to All and Events Delete None to Own against the saved role
|
|
When "Review differences" is pressed
|
|
Then a dialog "How the saved role differs from your edits · Jonas Weber saved changes to Contributor at 11:02." lists only the settings where the two disagree, as a table Setting, Saved, Yours: Events Update Own All; Events Delete None Own
|
|
And it offers "Use the saved role" and "Keep my edits"
|
|
And in FR the title is "Comment le rôle enregistré diffère de vos modifications" with "Réglage", "Enregistré", "Vous", "Utiliser le rôle enregistré", "Garder mes modifications"
|
|
|
|
Scenario Outline: Resolving
|
|
When the person presses <action>
|
|
Then <effect>
|
|
Examples:
|
|
| action | effect |
|
|
| Keep my edits | GET /v1/role/4 replaces the saved role and its version, the edits are re-applied on top, changed cells carry the square marker, Save is on, the dialog and the banner close, an info toast reads "Role reloaded · Your 2 changes are still here. Review them, then save." and the bar reads "2 unsaved changes" |
|
|
| Use the saved role | the edits are dropped, the form shows the saved role, Save is off, no toast about edits |
|
|
| Reload role (banner) | the same as "Use the saved role" |
|
|
| Escape or close | the dialog closes and the banner and kept edits stay |
|
|
And "Keep my edits" followed by Save sends PUT /v1/role/4 with the reloaded version
|
|
And in FR the toast reads "Rôle rechargé · Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez."
|
|
|
|
Scenario: A second conflict
|
|
Given edits were kept on top of version 3
|
|
When the save answers 409 role_conflict again
|
|
Then the banner returns with the newest updated_by and time, and the edits are still kept
|
|
|
|
Scenario: The name taken is not a conflict
|
|
When the save answers 409 role_name_taken
|
|
Then the existing field error shows and no banner
|
|
|
|
Scenario: The time is not clock-sensitive
|
|
Given updated_at is a fixed string and the time zone is pinned in the test and in the visual setup
|
|
Then "11:02" is the formatted time of that string, not the current time
|
|
|
|
Rule: Change role when the member's role changed first
|
|
|
|
Scenario: The alert in the dialog
|
|
Given the Change role dialog for Ines Duarte (Writer) is open with Contributor chosen
|
|
When PUT /v1/user/{id}/role answers 409 user_role_changed and GET /v1/user answers Ines Duarte with role Admin
|
|
Then the dialog stays open with an alert "Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again."
|
|
And Contributor stays chosen, the "Current" mark moves from Writer to Admin, and "Change role" stays enabled
|
|
And pressing "Change role" again sends PUT /v1/user/{id}/role {"role_id": <Contributor id>}
|
|
And in FR "Rôle modifié entre-temps · Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez."
|
|
And no toast shows while the dialog is open
|
|
|
|
Scenario: The chosen role became the current one
|
|
Given the re-read shows Ines Duarte now holds Contributor, the role the person chose
|
|
Then the alert still shows and "Change role" is disabled because the chosen role is now the current one
|
|
|
|
Scenario: The member disappeared
|
|
When GET /v1/user no longer lists Ines Duarte, or PUT answers 404 user_not_found
|
|
Then the dialog closes, the list is refreshed and the existing "no longer exists" toast of slice 7 shows
|
|
|
|
Scenario: The dialog is gone when the answer arrives (component level)
|
|
Given the dialog component was unmounted or its open state set to false before the 409 arrived
|
|
Then a warning toast reads "Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload"
|
|
And "Reload" refetches GET /v1/user and dismisses the toast
|
|
And in FR "Rôle non modifié · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger"
|
|
And this is specified at component level only, no page-level trigger exists because the dialog cannot be dismissed while sending
|
|
|
|
Rule: Transfer ownership when the target's role changed first
|
|
|
|
Scenario: The alert in the dialog
|
|
Given the Owner confirmed Jonas Weber and typed "jonas@renewbuild.example"
|
|
When POST /v1/site/transfer-ownership answers 409 user_role_changed and GET /v1/user answers Jonas Weber with role Writer
|
|
Then an alert in the dialog reads "Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again." with "Reload member"
|
|
And the typed email is kept and "Transfer ownership" is disabled until "Reload member" refreshes the target
|
|
And "Reload member" re-reads GET /v1/user, updates the shown role of the target and re-enables "Transfer ownership" with the typed email still valid
|
|
And in FR "Rôle modifié entre-temps · Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez. · Recharger le membre"
|
|
|
|
Scenario: The dialog is gone when the answer arrives (component level)
|
|
Then a warning toast reads "Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload"
|
|
And in FR "Propriété non transférée · Le rôle de Jonas Weber a changé entre-temps, donc rien n'a été transféré. Vérifiez-le et réessayez. · Recharger"
|
|
|
|
Scenario Outline: Other transfer answers keep the slice 7 behaviour
|
|
When the server answers <status> <code>
|
|
Then the behaviour of features/roles_spa_users.feature "Transfer answers" holds
|
|
Examples:
|
|
| status | code |
|
|
| 400 | invalid_transfer |
|
|
| 400 | confirmation_mismatch |
|
|
| 403 | missing_permission |
|
|
| 500 | (network) |
|
|
|
|
Rule: Role list actions
|
|
|
|
Scenario: Role edited elsewhere (409 on a list action)
|
|
Given the roles list was loaded and Contributor was edited by Jonas Weber afterwards
|
|
When Duplicate, Delete or Move members answers 409 role_conflict
|
|
Then a warning toast reads "Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload"
|
|
And "Reload" refetches GET /v1/role
|
|
And in FR "Rôle modifié ailleurs · Jonas Weber a modifié Contributor, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger"
|
|
|
|
Scenario: Role deleted elsewhere (404)
|
|
When an action answers 404 role_not_found
|
|
Then the row is removed from the list and an info toast reads "That role no longer exists · It was deleted somewhere else, so we removed it from the list." and closes after 6 s
|
|
And in FR "Ce rôle n'existe plus · Il a été supprimé ailleurs, nous l'avons donc retiré de la liste."
|
|
|
|
Scenario: Reassign target deleted (409 target_role_not_found)
|
|
Given the Delete role dialog of Intern is open with Writer chosen
|
|
When POST /v1/role/{id}/reassign answers 409 target_role_not_found
|
|
Then the inline error "Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to." of features/roles_sync_copy_activity.feature shows in the dialog, never a toast
|
|
And GET /v1/role is re-read so the picker no longer offers Writer
|