fix(ui): продолжать частичное сохранение пользователя без дублей

This commit is contained in:
Denozordec committed 2026-10-01 09:53:23 +07:00
1 parent 02ca48ef9c
commit 4896d8ef6f
9 files changed
+286 -113

No files matched your search

+33
View File
@@ -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:"[email protected]",email:"[email protected]",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,"Новое имя")
})
+60
View File
@@ -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 }
}