fix(ui): продолжать частичное сохранение пользователя без дублей
This commit is contained in:
1 parent
02ca48ef9c
commit
4896d8ef6f
9 files changed
+285
-112
No files matched your search
+63
-80
@@ -23,7 +23,6 @@ import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
ALL_SECTIONS,
|
||||
INIT_USERS,
|
||||
bindingDiffKey,
|
||||
userInitials,
|
||||
type AppUser,
|
||||
type AppUserForm,
|
||||
@@ -33,14 +32,16 @@ import { serverListSchema } from "@mmapp/contracts/servers"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { can } from "@/lib/auth"
|
||||
import {
|
||||
userApiError,
|
||||
createAppUser,
|
||||
getAppUser,
|
||||
createUserBinding,
|
||||
deleteAppUser,
|
||||
deleteUserBinding,
|
||||
listAppUsers,
|
||||
updateAppUser,
|
||||
} from "@/shared/api/users"
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
import { saveUserSteps, userProfileRequest, type UserSaveCheckpoint } from "@/lib/user-save"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
CableIcon,
|
||||
@@ -84,6 +85,10 @@ function UsersContent() {
|
||||
const loadController = useRef<AbortController | null>(null)
|
||||
const loadRevision = useRef(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saveBlocked, setSaveBlocked] = useState(false)
|
||||
const [saveResume, setSaveResume] = useState(false)
|
||||
const saveCheckpoint = useRef<UserSaveCheckpoint | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||||
@@ -109,7 +114,7 @@ function UsersContent() {
|
||||
if (current()) { setUsers(rows); setUsersReadAt(Date.now()); setUsersError(null) }
|
||||
return rows
|
||||
}).catch(error => {
|
||||
if (current()) setUsersError(error instanceof Error ? error.message : "Не удалось прочитать пользователей.")
|
||||
if (current()) setUsersError(userApiError(error))
|
||||
throw error
|
||||
}),
|
||||
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(raw => {
|
||||
@@ -123,7 +128,7 @@ function UsersContent() {
|
||||
}
|
||||
return rows
|
||||
}).catch(error => {
|
||||
if (current()) setServersError(error instanceof Error ? error.message : "Не удалось прочитать серверы.")
|
||||
if (current()) setServersError(userApiError(error))
|
||||
throw error
|
||||
}) : Promise.resolve(null),
|
||||
])
|
||||
@@ -144,85 +149,59 @@ function UsersContent() {
|
||||
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
|
||||
const activeCount = users.filter((u) => u.active).length
|
||||
|
||||
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
|
||||
const nextKeys = new Set(next.map(bindingDiffKey))
|
||||
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
|
||||
for (const b of prev) {
|
||||
if (!nextKeys.has(bindingDiffKey(b))) {
|
||||
await deleteUserBinding(backendUrl, userId, b.id)
|
||||
}
|
||||
}
|
||||
for (const b of next) {
|
||||
if (!prevKeys.has(bindingDiffKey(b))) {
|
||||
await createUserBinding(backendUrl, userId, {
|
||||
serverId: Number(b.serverId),
|
||||
interfaceName: b.interfaceName,
|
||||
interfaceType: b.interfaceType,
|
||||
peerPublicKey: b.peerPublicKey,
|
||||
peerName: b.peerName,
|
||||
comment: b.comment,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (form: AppUserForm) => {
|
||||
if (!writeReady || saving) return
|
||||
if (!writeReady || mutationLock.current || saveBlocked) return
|
||||
if (!isLive) {
|
||||
if (!editUser) {
|
||||
const id = `u${Date.now()}`
|
||||
const created: AppUser = {
|
||||
id,
|
||||
...form,
|
||||
last: "только что",
|
||||
avatar: userInitials(form.name),
|
||||
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
|
||||
const id = "u" + Date.now()
|
||||
setUsers(prev => [...prev, { id, ...form, last: "—", avatar: userInitials(form.name), bindings: form.bindings.map((b, i) => ({ ...b, id: "b" + id + "-" + i, userId: id })) }])
|
||||
} else setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
||||
setSheetOpen(false); toast.success("Профиль сохранён в демо."); return
|
||||
}
|
||||
setUsers((prev) => [...prev, created])
|
||||
} else {
|
||||
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
||||
}
|
||||
setSheetOpen(false)
|
||||
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
mutationLock.current = true
|
||||
const controller = new AbortController()
|
||||
mutationController.current = controller
|
||||
const current = () => alive.current && !controller.signal.aborted
|
||||
const init = () => ({ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||
const checkpoint = saveCheckpoint.current ?? { user: editUser }
|
||||
saveCheckpoint.current = checkpoint
|
||||
setSaving(true); setSaveError(null); setOperationNotice(null)
|
||||
try {
|
||||
if (!editUser) {
|
||||
const created = await createAppUser(backendUrl, {
|
||||
name: form.name,
|
||||
login: form.login,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
avatar: userInitials(form.name),
|
||||
sections: form.sections,
|
||||
servers: form.servers,
|
||||
})
|
||||
await applyBindingsDiff(created.id, form.bindings, [])
|
||||
await loadLive()
|
||||
} else {
|
||||
await updateAppUser(backendUrl, editUser.id, {
|
||||
name: form.name,
|
||||
login: form.login,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
avatar: userInitials(form.name),
|
||||
sections: form.sections,
|
||||
servers: form.servers,
|
||||
})
|
||||
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
|
||||
await loadLive()
|
||||
const saved = await saveUserSteps(form, checkpoint, {
|
||||
create: f => createAppUser(backendUrl, userProfileRequest(f), init()),
|
||||
update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f), init()),
|
||||
read: id => getAppUser(backendUrl, id, init()),
|
||||
removeBinding: (userId, id) => deleteUserBinding(backendUrl, userId, id, init()),
|
||||
addBinding: (userId, b) => createUserBinding(backendUrl, userId, {
|
||||
serverId: Number(b.serverId), interfaceName: b.interfaceName, interfaceType: b.interfaceType,
|
||||
peerPublicKey: b.peerPublicKey, peerName: b.peerName, comment: b.comment,
|
||||
}, init()),
|
||||
}, current, state => {
|
||||
if (!current()) return
|
||||
setSaveBlocked(!!state.createUncertain)
|
||||
if (state.user) {
|
||||
if (!editUser) setSaveResume(true)
|
||||
const confirmed = state.user
|
||||
setUsers(prev => prev.some(u => u.id === confirmed.id) ? prev.map(u => u.id === confirmed.id ? confirmed : u) : [...prev, confirmed])
|
||||
}
|
||||
setSheetOpen(false)
|
||||
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
|
||||
toast.error(msg)
|
||||
})
|
||||
if (!current() || !saved) return
|
||||
setSheetOpen(false); saveCheckpoint.current = null
|
||||
setOperationNotice("Профиль и привязки подтверждены API.")
|
||||
toast.success("Пользователь сохранён.")
|
||||
const refreshed = await loadLive()
|
||||
if (current() && !refreshed) setOperationNotice("Профиль и привязки подтверждены API. Обновление каталогов не удалось; повторите чтение.")
|
||||
} catch (error) {
|
||||
if (!current()) return
|
||||
const detail = userApiError(error)
|
||||
setSaveError(checkpoint.createUncertain
|
||||
? "Результат создания неизвестен. Повтор заблокирован: проверьте список пользователей. " + detail
|
||||
: checkpoint.bindingsNeedRead
|
||||
? "Профиль подтверждён API; привязки сохранены частично. При повторе сначала прочитаем их текущее состояние. " + detail
|
||||
: detail)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||
if (alive.current) setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +228,7 @@ function UsersContent() {
|
||||
const refreshed = await loadLive()
|
||||
if (current() && !refreshed) setOperationNotice("Удаление пользователя подтверждено API. Обновление каталогов не удалось; повторите чтение.")
|
||||
} catch (error) {
|
||||
if (current()) setDeleteError(error instanceof Error ? error.message : "Удаление не подтверждено.")
|
||||
if (current()) setDeleteError(userApiError(error))
|
||||
} finally {
|
||||
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||
if (alive.current) setDeleting(false)
|
||||
@@ -263,7 +242,7 @@ function UsersContent() {
|
||||
actions={
|
||||
<>
|
||||
{isLive && <Button type="button" size="sm" variant="outline" disabled={!canRead || loading || saving || deleting || sheetOpen || !!deleteTarget} onClick={() => void loadLive()}>Обновить пользователей</Button>}
|
||||
<Button type="button" size="sm" disabled={!writeReady || saving || deleting} onClick={() => { setEditUser(null); setSheetOpen(true) }}>
|
||||
<Button type="button" size="sm" disabled={!writeReady || saving || deleting} onClick={() => { saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(null); setSheetOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить пользователя
|
||||
</Button>
|
||||
</>
|
||||
@@ -316,7 +295,7 @@ function UsersContent() {
|
||||
allSectionsCount={ALL_SECTIONS.length}
|
||||
isLoading={loading && !usersKnown}
|
||||
actionsDisabled={!writeReady || saving || deleting}
|
||||
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
|
||||
onEdit={(u) => { saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(u); setSheetOpen(true) }}
|
||||
onDelete={u => { setDeleteError(null); setDeleteTarget(u) }}
|
||||
/>
|
||||
</DataPageCard>}
|
||||
@@ -331,8 +310,12 @@ function UsersContent() {
|
||||
isLive={isLive}
|
||||
backendUrl={backendUrl}
|
||||
saving={saving}
|
||||
saveError={saveError}
|
||||
saveBlocked={saveBlocked}
|
||||
saveResume={saveResume}
|
||||
onCheckCatalogs={() => void loadLive()}
|
||||
onSave={(f) => { void handleSave(f) }}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onClose={() => { if (!saving) { setSheetOpen(false); saveCheckpoint.current = null } }}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
|
||||
@@ -141,12 +141,16 @@ interface UserSheetProps {
|
||||
isLive: boolean
|
||||
backendUrl: string
|
||||
saving?: boolean
|
||||
saveError?: string | null
|
||||
saveBlocked?: boolean
|
||||
saveResume?: boolean
|
||||
onCheckCatalogs?: () => void
|
||||
onSave: (form: AppUserForm) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function UserSheet({
|
||||
open, user, users, servers, isLive, backendUrl, saving, onSave, onClose,
|
||||
open, user, users, servers, isLive, backendUrl, saving, saveError, saveBlocked, saveResume, onCheckCatalogs, onSave, onClose,
|
||||
}: UserSheetProps) {
|
||||
const isCreate = user === null
|
||||
const [sheetStep, setSheetStep] = useState(1)
|
||||
@@ -158,17 +162,6 @@ function UserSheet({
|
||||
const [expandedWg, setExpandedWg] = useState<string | null>(null)
|
||||
const [newComment, setNewComment] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setForm(user ? toForm(user) : emptyForm(servers))
|
||||
setSheetStep(1)
|
||||
setErrors({})
|
||||
setCatalogServerId(servers[0]?.id ?? "")
|
||||
setSelectedPicks([])
|
||||
setExpandedWg(null)
|
||||
setNewComment("")
|
||||
}, [open, user, servers])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !catalogServerId) {
|
||||
setCatalog([])
|
||||
@@ -298,7 +291,7 @@ function UserSheet({
|
||||
const selectedPickKeys = useMemo(() => new Set(selectedPicks.map(pickKey)), [selectedPicks])
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v && !saving) onClose() }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>{isCreate ? "Новый пользователь" : "Редактировать пользователя"}</SheetTitle>
|
||||
@@ -307,6 +300,8 @@ function UserSheet({
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{saveError && <div role="alert" aria-label="Сохранение пользователя" className="px-6 py-3 text-sm text-destructive">{saveError}{saveBlocked && <Button type="button" variant="outline" disabled={saving} onClick={onCheckCatalogs}>Проверить список пользователей</Button>}</div>}
|
||||
<fieldset disabled={saving} className="flex-1 min-h-0 flex flex-col">
|
||||
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||
<StepperNav className="mb-5">
|
||||
<StepperItem step={1}>
|
||||
@@ -670,18 +665,19 @@ function UserSheet({
|
||||
</Stepper>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
||||
<SheetClose render={<Button type="button" variant="outline" disabled={saving} />}>Отмена</SheetClose>
|
||||
{sheetStep > 1 && (
|
||||
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||
)}
|
||||
{sheetStep < 4 ? (
|
||||
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||
) : (
|
||||
<Button className="ml-auto" disabled={saving} onClick={submit}>
|
||||
{isCreate ? "Создать" : "Сохранить"}
|
||||
<Button className="ml-auto" disabled={saving || saveBlocked} aria-busy={saving} onClick={submit}>
|
||||
{saveResume ? "Продолжить сохранение" : isCreate ? "Создать" : "Сохранить"}
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</fieldset>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
|
||||
+10
-1
@@ -27,7 +27,7 @@
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.3; загрузка и удаление пользователей приняты локально, сохранение далее |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.4; загрузка, удаление и сохранение пользователей приняты локально, каталог/UX далее |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11А.4 — многошаговое сохранение профиля и привязок (2026-10-01)
|
||||
|
||||
- Общий mutation lock, отмена/30s срок каждого запроса, защита прежнего источника; Sheet не сбрасывает черновик при изменении каталога/подтверждённом промежуточном шаге. Во время отправки поля/кнопки и закрытие блокируются.
|
||||
- UserSaveCheckpoint сохраняет подтверждённый id профиля и каждую привязку. После частичного отказа повтор читает профиль/привязки, обновляет профиль по этому id и продолжает diff без второго POST пользователя/повторной подтверждённой привязки. Потерянный ответ DELETE/POST привязки сверяется чтением. Это несколько HTTP запросов, не атомарная транзакция.
|
||||
- При неизвестном исходе POST профиля повтор заблокирован; доступно чтение списка, черновик сохранён. 4xx создания допускает исправление/повтор. UI не присваивает найденный по login чужой профиль автоматически. Подтверждённый профиль/привязки отделены от отказа refresh.
|
||||
- Ответы проверяют userId/login, отправленные поля/права и контекст привязки. Wrong role/sections/servers не подтверждают сохранение. IDs в URL кодируются. Техническая многострочная ошибка схемы заменена понятной краткой ошибкой — иначе footer выходил за экран; обнаружено E2E, исправлено и повторено без ослабления теста.
|
||||
- 5 fault-injection unit, 27 различных data/operations E2E и 8 context E2E прошли; TypeScript, профильный lint и production frontend прошли. В UserSheet остаётся одна исходная catalog set-state-in-effect ошибка; она входит в следующий блок, suppressions нет.
|
||||
- Далее 11А.5: строгий interface catalog, loading/error/empty, точный сервер и поздние ответы/peer сбои; затем форма/таблица, клавиатура и ПК/телефон. Реальная БД/Portal не проверены.
|
||||
|
||||
## Блок 11А.3 — подтверждённое удаление пользователя (2026-10-01)
|
||||
|
||||
- DELETE имеет single-flight lock, AbortSignal/30s срок и защиту позднего ответа после размонтирования. Ожидание блокирует повтор/отмену диалога; ошибка остаётся в диалоге с возможностью повторить, target не теряется.
|
||||
|
||||
@@ -57,3 +57,13 @@
|
||||
- Удаление требует существующий backend контракт HTTP 204; успешный 200 с произвольным телом не принимается. Подтверждённая строка удаляется сразу, а отказ последующего refresh не называется отказом удаления: API результат и необходимость повторного чтения показаны отдельно.
|
||||
- 17 data/operations E2E + 6 context E2E, TypeScript и профильный lint прошли. Проверены 403/503/неверный ответ, двойной click, Escape, refresh error, поздний success/error прежнего backend. Созданы общие HTTP-фикстуры пользователей.
|
||||
- Далее 11А.4: безопасное сохранение профиля/черновика и частичных привязок, без повторного создания при подтверждённом первом шаге. Сохранение ещё не принято; interface catalog и визуальная приёмка далее. Реальный Portal/БД не проверен.
|
||||
|
||||
|
||||
## Блок 11А.4 — многошаговое сохранение профиля и привязок (2026-10-01)
|
||||
|
||||
- Общий mutation lock, отмена/30s срок каждого запроса, защита прежнего источника; Sheet не сбрасывает черновик при изменении каталога/подтверждённом промежуточном шаге. Во время отправки поля/кнопки и закрытие блокируются.
|
||||
- UserSaveCheckpoint сохраняет подтверждённый id профиля и каждую привязку. После частичного отказа повтор читает профиль/привязки, обновляет профиль по этому id и продолжает diff без второго POST пользователя/повторной подтверждённой привязки. Потерянный ответ DELETE/POST привязки сверяется чтением. Это несколько HTTP запросов, не атомарная транзакция.
|
||||
- При неизвестном исходе POST профиля повтор заблокирован; доступно чтение списка, черновик сохранён. 4xx создания допускает исправление/повтор. UI не присваивает найденный по login чужой профиль автоматически. Подтверждённый профиль/привязки отделены от отказа refresh.
|
||||
- Ответы проверяют userId/login, отправленные поля/права и контекст привязки. Wrong role/sections/servers не подтверждают сохранение. IDs в URL кодируются. Техническая многострочная ошибка схемы заменена понятной краткой ошибкой — иначе footer выходил за экран; обнаружено E2E, исправлено и повторено без ослабления теста.
|
||||
- 5 fault-injection unit, 27 различных data/operations E2E и 8 context E2E прошли; TypeScript, профильный lint и production frontend прошли. В UserSheet остаётся одна исходная catalog set-state-in-effect ошибка; она входит в следующий блок, suppressions нет.
|
||||
- Далее 11А.5: строгий interface catalog, loading/error/empty, точный сервер и поздние ответы/peer сбои; затем форма/таблица, клавиатура и ПК/телефон. Реальная БД/Portal не проверены.
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { saveUserSteps, type UserSaveCheckpoint, type UserSaveApi } from "./user-save"
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
import type { AppUser, AppUserForm, InterfaceBinding } from "./users"
|
||||
const user:AppUser={id:"u1",name:"Тест",login:"test@example.test",email:"test@example.test",role:"viewer",active:true,avatar:"Т",last:"—",sections:[],servers:[],bindings:[]}
|
||||
const binding=(name:string):InterfaceBinding=>({id:"draft-"+name,userId:"",serverId:"1",serverName:"Сервер",serverSite:"",serverCountry:"RU",interfaceName:name,interfaceType:"ether",comment:""})
|
||||
const form:AppUserForm={...user,bindings:[binding("ether1"),binding("ether2")]}
|
||||
function api(overrides:Partial<UserSaveApi>={}):UserSaveApi{return{create:async()=>({...user}),update:async()=>({...user}),read:async()=>({...user}),removeBinding:async()=>{},addBinding:async(_,b)=>({...b,id:"saved-"+b.interfaceName,userId:"u1"}),...overrides}}
|
||||
test("частичные привязки: повтор читает сервер, не создаёт второй профиль и не дублирует привязку",async()=>{
|
||||
const state:UserSaveCheckpoint={user:null},calls:string[]=[];let fail=true,remote:AppUser={...user}
|
||||
const service=api({create:async()=>{calls.push("create");return remote},read:async()=>{calls.push("read");return remote},update:async()=>{calls.push("patch");return remote},addBinding:async(_,b)=>{calls.push("add:"+b.interfaceName);if(b.interfaceName==="ether2"&&fail)throw new ApiClientError("conflict",409);const saved={...b,id:"saved-"+b.interfaceName,userId:"u1"};remote={...remote,bindings:[...remote.bindings,saved]};return saved}})
|
||||
await assert.rejects(saveUserSteps(form,state,service,()=>true,()=>{}),/conflict/);assert.equal(state.user?.bindings.length,1);assert.equal(state.bindingsNeedRead,true)
|
||||
fail=false;const saved=await saveUserSteps(form,state,service,()=>true,()=>{});assert.equal(saved?.bindings.length,2);assert.deepEqual(calls,["create","add:ether1","add:ether2","read","patch","add:ether2"])
|
||||
})
|
||||
test("потерянный ответ удаления: read подтверждает удаление, повтор не отправляет второй DELETE",async()=>{
|
||||
const b={...binding("ether1"),id:"old",userId:"u1"};let remote:AppUser={...user,bindings:[b]},deletes=0
|
||||
const state:UserSaveCheckpoint={user:remote},service=api({update:async()=>remote,read:async()=>remote,removeBinding:async()=>{deletes++;remote={...remote,bindings:[]};throw new Error("socket closed")}})
|
||||
await assert.rejects(saveUserSteps({...form,bindings:[]},state,service,()=>true,()=>{}),/socket/);await saveUserSteps({...form,bindings:[]},state,service,()=>true,()=>{});assert.equal(deletes,1);assert.equal(state.user?.bindings.length,0)
|
||||
})
|
||||
test("неизвестный исход создания запрещает повторный POST; 403 допускает исправление",async()=>{
|
||||
for(const known of [false,true]){let creates=0;const state:UserSaveCheckpoint={user:null};const service=api({create:async()=>{creates++;throw new ApiClientError("fail",known?403:500)}})
|
||||
await assert.rejects(saveUserSteps(form,state,service,()=>true,()=>{}));assert.equal(!!state.createUncertain,!known);await assert.rejects(saveUserSteps(form,state,service,()=>true,()=>{}));assert.equal(creates,known?2:1)}
|
||||
})
|
||||
test("поздний профиль прежнего контекста не начинает следующие сетевые шаги",async()=>{
|
||||
let active=true,adds=0,changes=0;const state:UserSaveCheckpoint={user:null},service=api({create:async()=>{active=false;return user},addBinding:async(_,b)=>{adds++;return b}})
|
||||
assert.equal(await saveUserSteps(form,state,service,()=>active,()=>{changes++}),null);assert.equal(adds,0);assert.equal(changes,0);assert.equal(state.user,null)
|
||||
})
|
||||
test("изменённый профиль после частичного шага PATCH идёт по подтверждённому id",async()=>{
|
||||
const state:UserSaveCheckpoint={user,profileSignature:"previous"};let id=""
|
||||
const service=api({update:async(userId,f)=>{id=userId;return{...user,name:f.name}}})
|
||||
const saved=await saveUserSteps({...form,name:"Новое имя",bindings:[]},state,service,()=>true,()=>{});assert.equal(id,"u1");assert.equal(saved?.name,"Новое имя")
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { bindingDiffKey, userInitials, type AppUser, type AppUserForm, type InterfaceBinding } from "@/lib/users"
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
export interface UserSaveCheckpoint { user: AppUser | null; profileSignature?: string; bindingsNeedRead?: boolean; createUncertain?: boolean }
|
||||
export interface UserSaveApi {
|
||||
create: (form: AppUserForm) => Promise<AppUser>
|
||||
update: (id: string, form: AppUserForm) => Promise<AppUser>
|
||||
read: (id: string) => Promise<AppUser>
|
||||
removeBinding: (userId: string, bindingId: string) => Promise<void>
|
||||
addBinding: (userId: string, binding: InterfaceBinding) => Promise<InterfaceBinding>
|
||||
}
|
||||
/** Подтверждённые шаги нескольких HTTP запросов, а не общая транзакция. */
|
||||
export async function saveUserSteps(form: AppUserForm, checkpoint: UserSaveCheckpoint, api: UserSaveApi, current: () => boolean, changed: (state: UserSaveCheckpoint) => void): Promise<AppUser | null> {
|
||||
if (!current()) return null
|
||||
if (checkpoint.createUncertain) throw new Error("Результат создания неизвестен. Проверьте список пользователей перед новой попыткой; повторное создание заблокировано.")
|
||||
const { bindings: desired, ...profile } = form
|
||||
const signature = JSON.stringify(profile)
|
||||
if (checkpoint.bindingsNeedRead && checkpoint.user) {
|
||||
const user = await api.read(checkpoint.user.id)
|
||||
if (!current()) return null
|
||||
checkpoint.user = user; checkpoint.bindingsNeedRead = false; checkpoint.profileSignature = undefined; changed(checkpoint)
|
||||
}
|
||||
if (!checkpoint.user) {
|
||||
try {
|
||||
const user = await api.create(form)
|
||||
if (!current()) return null
|
||||
checkpoint.user = user; checkpoint.profileSignature = signature; changed(checkpoint)
|
||||
} catch (error) {
|
||||
if (current() && !(error instanceof ApiClientError && error.status >= 400 && error.status < 500)) { checkpoint.createUncertain = true; changed(checkpoint) }
|
||||
throw error
|
||||
}
|
||||
} else if (checkpoint.profileSignature !== signature) {
|
||||
const user = await api.update(checkpoint.user.id, form)
|
||||
if (!current()) return null
|
||||
checkpoint.user = user; checkpoint.profileSignature = signature; changed(checkpoint)
|
||||
}
|
||||
if (!checkpoint.user || !current()) return null
|
||||
try {
|
||||
const desiredKeys = new Set(desired.map(bindingDiffKey))
|
||||
for (const binding of [...checkpoint.user.bindings]) {
|
||||
if (desiredKeys.has(bindingDiffKey(binding))) continue
|
||||
await api.removeBinding(checkpoint.user.id, binding.id)
|
||||
if (!current()) return null
|
||||
checkpoint.user = { ...checkpoint.user, bindings: checkpoint.user.bindings.filter(b => b.id !== binding.id) }; changed(checkpoint)
|
||||
}
|
||||
const knownKeys = new Set(checkpoint.user.bindings.map(bindingDiffKey))
|
||||
for (const binding of desired) {
|
||||
if (knownKeys.has(bindingDiffKey(binding))) continue
|
||||
const added = await api.addBinding(checkpoint.user.id, binding)
|
||||
if (!current()) return null
|
||||
checkpoint.user = { ...checkpoint.user, bindings: [...checkpoint.user.bindings, added] }; knownKeys.add(bindingDiffKey(added)); changed(checkpoint)
|
||||
}
|
||||
} catch (error) {
|
||||
if (current()) { checkpoint.bindingsNeedRead = true; changed(checkpoint) }
|
||||
throw error
|
||||
}
|
||||
return checkpoint.user
|
||||
}
|
||||
export function userProfileRequest(form: AppUserForm) {
|
||||
return { name: form.name.trim(), login: form.login.trim(), email: form.email.trim(), role: form.role, active: form.active, avatar: userInitials(form.name), sections: form.sections, servers: form.servers }
|
||||
}
|
||||
+54
-15
@@ -13,8 +13,10 @@ import { z } from "zod"
|
||||
import { requestJson, requestBlob } from "@/shared/api/http-client"
|
||||
import type { AppUser, CatalogIface, InterfaceBinding } from "@/lib/users"
|
||||
|
||||
const usersListPayload = z.object({ users: z.array(appUserReadSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторяющиеся пользователи в ответе API") })
|
||||
const userPayload = z.object({ user: appUserReadSchema })
|
||||
const checkedUserSchema = appUserReadSchema.refine(user => user.bindings.every(binding => binding.userId === user.id) && new Set(user.bindings.map(binding => binding.id)).size === user.bindings.length, "Некорректный контекст привязок пользователя")
|
||||
|
||||
const usersListPayload = z.object({ users: z.array(checkedUserSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторяющиеся пользователи в ответе API") })
|
||||
const userPayload = z.object({ user: checkedUserSchema })
|
||||
const bindingPayload = z.object({ binding: userBindingSchema })
|
||||
const catalogPayload = z.object({ interfaces: z.array(catalogInterfaceSchema) })
|
||||
|
||||
@@ -55,20 +57,50 @@ export async function listAppUsers(baseUrl: string, init?: RequestInit): Promise
|
||||
return usersListPayload.parse(payload).users.map(toFrontendUser)
|
||||
}
|
||||
|
||||
export async function createAppUser(baseUrl: string, data: AppUserCreate): Promise<AppUser> {
|
||||
export async function getAppUser(baseUrl: string, id: string, init?: RequestInit): Promise<AppUser> {
|
||||
const payload = await requestJson<unknown>(baseUrl, "/api/users/" + encodeURIComponent(id), init)
|
||||
const user = toFrontendUser(userPayload.parse(payload).user)
|
||||
if (user.id !== id) throw new Error("Не подтверждён контекст пользователя.")
|
||||
return user
|
||||
}
|
||||
|
||||
function confirmProfile(user: AppUserRead, data: AppUserCreate | AppUserUpdate) {
|
||||
for (const key of ["name", "login", "email"] as const) {
|
||||
if (data[key] !== undefined && user[key] !== data[key]!.trim()) throw new Error("Профиль не подтверждён: ответ содержит другие поля.")
|
||||
}
|
||||
for (const key of ["role", "active"] as const) {
|
||||
if (data[key] !== undefined && user[key] !== data[key]) throw new Error("Профиль не подтверждён: ответ содержит другие права или состояние.")
|
||||
}
|
||||
const canonical = (items: unknown[]) => items.map(item => JSON.stringify(Object.entries(item as object).sort(([a], [b]) => a.localeCompare(b)))).sort().join("\n")
|
||||
for (const key of ["sections", "servers"] as const) {
|
||||
if (data[key] !== undefined && canonical(user[key]) !== canonical(data[key]!)) throw new Error("Профиль не подтверждён: разрешения отличаются от отправленных.")
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAppUser(baseUrl: string, data: AppUserCreate, init?: RequestInit): Promise<AppUser> {
|
||||
const payload = await requestJson<unknown>(baseUrl, "/api/users", {
|
||||
...init,
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return toFrontendUser(userPayload.parse(payload).user)
|
||||
const parsed = userPayload.parse(payload).user
|
||||
confirmProfile(parsed, data)
|
||||
const user = toFrontendUser(parsed)
|
||||
if (user.login !== data.login.trim()) throw new Error("Создание пользователя не подтверждено: другой логин.")
|
||||
return user
|
||||
}
|
||||
|
||||
export async function updateAppUser(baseUrl: string, id: string, data: AppUserUpdate): Promise<AppUser> {
|
||||
const payload = await requestJson<unknown>(baseUrl, `/api/users/${id}`, {
|
||||
export async function updateAppUser(baseUrl: string, id: string, data: AppUserUpdate, init?: RequestInit): Promise<AppUser> {
|
||||
const payload = await requestJson<unknown>(baseUrl, `/api/users/${encodeURIComponent(id)}`, {
|
||||
...init,
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return toFrontendUser(userPayload.parse(payload).user)
|
||||
const parsed = userPayload.parse(payload).user
|
||||
confirmProfile(parsed, data)
|
||||
const user = toFrontendUser(parsed)
|
||||
if (user.id !== id) throw new Error("Сохранение пользователя не подтверждено: другой пользователь.")
|
||||
return user
|
||||
}
|
||||
|
||||
export async function deleteAppUser(baseUrl: string, id: string, init?: RequestInit): Promise<void> {
|
||||
@@ -80,20 +112,21 @@ export async function createUserBinding(
|
||||
baseUrl: string,
|
||||
userId: string,
|
||||
data: UserBindingCreate,
|
||||
init?: RequestInit,
|
||||
): Promise<InterfaceBinding> {
|
||||
const payload = await requestJson<unknown>(baseUrl, `/api/users/${userId}/bindings`, {
|
||||
const payload = await requestJson<unknown>(baseUrl, `/api/users/${encodeURIComponent(userId)}/bindings`, {
|
||||
...init,
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return toFrontendBinding(bindingPayload.parse(payload).binding)
|
||||
const binding = toFrontendBinding(bindingPayload.parse(payload).binding)
|
||||
if (binding.userId !== userId || binding.serverId !== String(data.serverId) || binding.interfaceName !== data.interfaceName.trim() || (binding.peerPublicKey ?? "") !== (data.peerPublicKey ?? "").trim()) throw new Error("Привязка не подтверждена: ответ другого объекта.")
|
||||
return binding
|
||||
}
|
||||
|
||||
export async function deleteUserBinding(
|
||||
baseUrl: string,
|
||||
userId: string,
|
||||
bindingId: string,
|
||||
): Promise<void> {
|
||||
await requestJson<void>(baseUrl, `/api/users/${userId}/bindings/${bindingId}`, { method: "DELETE" })
|
||||
export async function deleteUserBinding(baseUrl: string, userId: string, bindingId: string, init?: RequestInit): Promise<void> {
|
||||
const response = await requestBlob(baseUrl, "/api/users/" + encodeURIComponent(userId) + "/bindings/" + encodeURIComponent(bindingId), { ...init, method: "DELETE" })
|
||||
if (response.status !== 204) throw new Error("Удаление привязки не подтверждено: ожидается HTTP 204.")
|
||||
}
|
||||
|
||||
export async function listInterfaceCatalog(
|
||||
@@ -106,3 +139,9 @@ export async function listInterfaceCatalog(
|
||||
)
|
||||
return catalogPayload.parse(payload).interfaces as CatalogInterface[]
|
||||
}
|
||||
|
||||
export function userApiError(error: unknown): string {
|
||||
if (error instanceof z.ZodError) return "Некорректный ответ API. Данные не подтверждены."
|
||||
if (error instanceof Error) return error.message.length > 500 ? error.message.slice(0, 500) + "…" : error.message
|
||||
return "Запрос не подтверждён."
|
||||
}
|
||||
@@ -21,3 +21,12 @@ for(const fails of [false,true])test("Пользователи: позднее
|
||||
await page.goto("/settings");const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter");await page.goto("/users");await expect(page.getByText(user.name,{exact:true})).toBeVisible();release();await expect.poll(()=>completed).toBe(true)
|
||||
await expect(page.getByText(user.name,{exact:true})).toBeVisible();await expect(page.getByText("СТАРОЕ УДАЛЕНИЕ ОТКЛОНЕНО",{exact:true})).toHaveCount(0);await expect(page.getByRole("status").filter({hasText:"Удаление пользователя подтверждено"})).toHaveCount(0)
|
||||
})
|
||||
|
||||
for(const fails of [false,true])test("Пользователи: позднее создание не меняет новый backend/"+fails,async({page})=>{
|
||||
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
|
||||
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[user]}:[]})})
|
||||
const catalog={id:1,name:"Сервер",host:"192.0.2.1",port:443,useSsl:true,verifySsl:false,username:"admin",password:"",type:"exit-node",site:"",country:"RU",asn:"",comment:"",enabled:true,lanSubnet:"",wanUplinks:[],createdAt:"",updatedAt:"",status:null,latency:null,os:null,model:null,uptime:null,cpuLoad:null,freeMemory:null,totalMemory:null,identityName:null,sessions:0,polledAt:null}
|
||||
await page.route("**/api/servers",r=>r.fulfill({headers,json:[catalog]}));await page.route("**/api/users",async r=>{if(r.request().method()!=="POST")return r.fallback();started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРОЕ СОЗДАНИЕ ОТКЛОНЕНО"}}:{headers,status:201,json:{user:{...user,...r.request().postDataJSON(),id:"old-created",name:"СТАРЫЙ СОЗДАННЫЙ"}}})}catch{}finally{completed=true}})
|
||||
await page.goto("/users");await page.getByRole("button",{name:"Добавить пользователя",exact:true}).click();await page.getByPlaceholder("Иван Иванов").fill("СТАРЫЙ СОЗДАННЫЙ");await page.getByPlaceholder("i.ivanov@company.io").fill("old@example.test");const dialog=page.getByRole("dialog");for(let i=0;i<3;i++)await dialog.getByRole("button",{name:"Далее",exact:true}).click();await dialog.getByRole("button",{name:"Создать",exact:true}).click();await expect.poll(()=>started).toBe(true)
|
||||
await page.goto("/settings");const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter");await page.goto("/users");await expect(page.getByText(user.name,{exact:true})).toBeVisible();release();await expect.poll(()=>completed).toBe(true);await expect(page.getByText("СТАРЫЙ СОЗДАННЫЙ",{exact:true})).toHaveCount(0);await expect(page.getByText("СТАРОЕ СОЗДАНИЕ ОТКЛОНЕНО",{exact:true})).toHaveCount(0);await expect(page.getByRole("status").filter({hasText:"Профиль и привязки подтверждены"})).toHaveCount(0)
|
||||
})
|
||||
@@ -19,3 +19,37 @@ test("Удаление пользователя: подтверждение от
|
||||
await page.goto("/users");await page.getByRole("button",{name:"Удалить пользователя "+user.name}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click();await expect(page.getByRole("status").filter({hasText:"Удаление пользователя подтверждено API. Обновление каталогов не удалось"})).toBeVisible();await expect(page.getByRole("alert",{name:"Пользователи",exact:true})).toContainText("Список не обновлён");await expect(page.getByText(user.name,{exact:true})).toHaveCount(0)
|
||||
fail=false;await page.getByRole("button",{name:"Обновить пользователей",exact:true}).click();await expect(page.getByRole("button",{name:"Добавить пользователя",exact:true})).toBeEnabled()
|
||||
})
|
||||
|
||||
async function openCreate(page:import("@playwright/test").Page){
|
||||
await page.getByRole("button",{name:"Добавить пользователя",exact:true}).click();await page.getByPlaceholder("Иван Иванов").fill("Новый профиль");await page.getByPlaceholder("i.ivanov@company.io").fill("new@example.test");for(let i=0;i<3;i++)await page.getByRole("dialog").getByRole("button",{name:"Далее",exact:true}).click()
|
||||
}
|
||||
for(const kind of ["403","malformed"] as const)test("Создание пользователя: сохранение черновика "+kind,async({page})=>{
|
||||
let fail=true,created=false,posts=0;await page.route("**/api/users",r=>{if(r.request().method()==="POST"){posts++;if(fail)return r.fulfill(kind==="403"?{status:403,json:{error:"Создание запрещено"}}:{json:{user:{}}});created=true;return r.fulfill({status:201,json:{user:{...user,...r.request().postDataJSON(),id:"new"}}})}return r.fulfill({json:{users:created?[{...user,id:"new",name:"Новый профиль",login:"new@example.test",email:"new@example.test"}]:[user]}})})
|
||||
await page.goto("/users");await openCreate(page);const dialog=page.getByRole("dialog");await dialog.getByRole("button",{name:"Создать",exact:true}).click();await expect(dialog.getByRole("alert",{name:"Сохранение пользователя"})).toBeVisible();await dialog.getByRole("button",{name:"Назад",exact:true}).click();await dialog.getByRole("button",{name:"Назад",exact:true}).click();await dialog.getByRole("button",{name:"Назад",exact:true}).click();await expect(page.getByPlaceholder("Иван Иванов")).toHaveValue("Новый профиль");await expect(page.getByPlaceholder("i.ivanov@company.io")).toHaveValue("new@example.test")
|
||||
if(kind==="403"){fail=false;for(let i=0;i<3;i++)await dialog.getByRole("button",{name:"Далее",exact:true}).click();await dialog.getByRole("button",{name:"Создать",exact:true}).click();await expect(dialog).toHaveCount(0);await expect(page.getByRole("status").filter({hasText:"Профиль и привязки подтверждены API."})).toBeVisible();expect(posts).toBe(2)}
|
||||
else{for(let i=0;i<3;i++)await dialog.getByRole("button",{name:"Далее",exact:true}).click();await expect(dialog.getByRole("button",{name:"Создать",exact:true})).toBeDisabled();await dialog.getByRole("button",{name:"Проверить список пользователей"}).click();await expect(dialog).toBeVisible();expect(posts).toBe(1)}
|
||||
})
|
||||
test("Создание пользователя: lock формы и успех отдельно от refresh",async({page})=>{
|
||||
let posts=0,created=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
|
||||
await page.route("**/api/users",async r=>{if(r.request().method()==="POST"){posts++;await gate;created=true;return r.fulfill({status:201,json:{user:{...user,...r.request().postDataJSON(),id:"new"}}})}return r.fulfill(created?{status:503,json:{error:"REFRESH профиля недоступен"}}:{json:{users:[user]}})})
|
||||
await page.goto("/users");await openCreate(page);const dialog=page.getByRole("dialog");await dialog.getByRole("button",{name:"Создать",exact:true}).evaluate(el=>{(el as HTMLButtonElement).click();(el as HTMLButtonElement).click()});await expect.poll(()=>posts).toBe(1);await expect(dialog.getByRole("button",{name:"Отмена",exact:true})).toBeDisabled();await page.keyboard.press("Escape");await expect(dialog).toBeVisible();release();await expect(dialog).toHaveCount(0);await expect(page.getByText("Новый профиль",{exact:true})).toBeVisible();await expect(page.getByRole("status").filter({hasText:"Профиль и привязки подтверждены API. Обновление каталогов не удалось"})).toBeVisible()
|
||||
})
|
||||
for(const bad of [true,false])test("Редактирование пользователя: неверный id и ошибка API "+bad,async({page})=>{
|
||||
let fail=true;await page.route("**/api/users/u-test",r=>r.fulfill(fail?(bad?{json:{user:{...user,id:"foreign"}}}:{status:503,json:{error:"PATCH отклонён"}}):{json:{user:{...user,...r.request().postDataJSON()}}}))
|
||||
await page.goto("/users");await page.getByRole("button",{name:"Редактировать пользователя "+user.name}).click();await page.getByPlaceholder("Иван Иванов").fill("Исправленный профиль");const dialog=page.getByRole("dialog");for(let i=0;i<3;i++)await dialog.getByRole("button",{name:"Далее",exact:true}).click();await dialog.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(dialog.getByRole("alert",{name:"Сохранение пользователя"})).toBeVisible();fail=false;await dialog.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
for(const lost of [false,true])test("Создание пользователя: частичные привязки и повтор без дублей "+lost,async({page})=>{
|
||||
let fail=true,profiles=0,patches=0;const adds:string[]=[];let profile={...user,id:"new",name:"Новый профиль",login:"new@example.test",email:"new@example.test"};const bindings:Array<Record<string,unknown>>=[]
|
||||
await page.route("**/api/users/interface-catalog?*",r=>r.fulfill({json:{interfaces:["ether1","ether2"].map(name=>({name,type:"ether",running:true,disabled:false,boundUserId:null,boundUserLogin:null}))}}))
|
||||
await page.route("**/api/users",r=>{if(r.request().method()==="POST"){profiles++;profile={...profile,...r.request().postDataJSON()};return r.fulfill({status:201,json:{user:{...profile,bindings}}})}return r.fulfill({json:{users:[user,{...profile,bindings}]}})})
|
||||
await page.route("**/api/users/new",r=>{if(r.request().method()==="PATCH"){patches++;profile={...profile,...r.request().postDataJSON()}}return r.fulfill({json:{user:{...profile,bindings}}})})
|
||||
await page.route("**/api/users/new/bindings",r=>{const data=r.request().postDataJSON();adds.push(data.interfaceName);const binding={...data,id:"saved-"+data.interfaceName,userId:"new",serverName:"Роутер",serverSite:"",serverCountry:"RU",createdAt:"",updatedAt:"",peerPublicKey:"",peerName:"",comment:""};if(fail&&((lost&&data.interfaceName==="ether1")||(!lost&&data.interfaceName==="ether2"))){if(lost)bindings.push(binding);return r.fulfill({status:lost?500:409,json:{error:"ПРИВЯЗКА НЕ ПОДТВЕРЖДЕНА"}})}bindings.push(binding);return r.fulfill({status:201,json:{binding}})})
|
||||
await page.goto("/users");await openCreate(page);const dialog=page.getByRole("dialog");await dialog.getByRole("button",{name:/ether1.*Ethernet/}).click();await dialog.getByRole("button",{name:/ether2.*Ethernet/}).click();await dialog.getByRole("button",{name:"Привязать",exact:true}).click();await dialog.getByRole("button",{name:"Создать",exact:true}).click();await expect(dialog.getByRole("alert",{name:"Сохранение пользователя"})).toContainText("привязки сохранены частично");await expect(dialog.getByRole("button",{name:"Продолжить сохранение",exact:true})).toBeEnabled()
|
||||
fail=false;await dialog.getByRole("button",{name:"Продолжить сохранение",exact:true}).click();await expect(dialog).toHaveCount(0);expect(profiles).toBe(1);expect(patches).toBe(1);expect(adds.filter(name=>name==="ether1")).toHaveLength(1);expect(bindings).toHaveLength(2);expect(adds.filter(name=>name==="ether2")).toHaveLength(lost?1:2)
|
||||
})
|
||||
|
||||
for(const field of ["role","sections","servers"] as const)test("Сохранение профиля: ответ с другими разрешениями "+field,async({page})=>{
|
||||
let fail=true;await page.route("**/api/users/u-test",r=>{const data=r.request().postDataJSON();return r.fulfill({json:{user:{...user,...data,...(fail?(field==="role"?{role:"admin"}:field==="sections"?{sections:[]}:{servers:[]}):{})}}})})
|
||||
await page.goto("/users");await page.getByRole("button",{name:"Редактировать пользователя "+user.name}).click();const dialog=page.getByRole("dialog");await dialog.getByRole("button",{name:"Оператор",exact:true}).click();for(let i=0;i<3;i++)await dialog.getByRole("button",{name:"Далее",exact:true}).click();await dialog.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(dialog.getByRole("alert",{name:"Сохранение пользователя"})).toContainText("не подтверждён");fail=false;await dialog.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
Reference in new issue
Block a user