fix(ui): продолжать частичное сохранение пользователя без дублей
This commit is contained in:
1 parent
02ca48ef9c
commit
4896d8ef6f
9 files changed
+286
-113
No files matched your search
@@ -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,"Новое имя")
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user