feat: unify moba workspace and persist session tree layout
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.dto.SessionTreeLayoutDto;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.SessionTreeLayoutService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/session-tree")
|
||||
public class SessionTreeLayoutController {
|
||||
|
||||
private final SessionTreeLayoutService sessionTreeLayoutService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public SessionTreeLayoutController(SessionTreeLayoutService sessionTreeLayoutService,
|
||||
UserRepository userRepository) {
|
||||
this.sessionTreeLayoutService = sessionTreeLayoutService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<SessionTreeLayoutDto> getLayout(Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(sessionTreeLayoutService.getLayout(userId));
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public ResponseEntity<SessionTreeLayoutDto> saveLayout(@RequestBody SessionTreeLayoutDto request,
|
||||
Authentication authentication) {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
return ResponseEntity.ok(sessionTreeLayoutService.saveLayout(userId, request));
|
||||
}
|
||||
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||
return user.getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SessionTreeLayoutDto {
|
||||
|
||||
private List<SessionTreeNodeDto> nodes = new ArrayList<SessionTreeNodeDto>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.sshmanager.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SessionTreeNodeDto {
|
||||
|
||||
private String id;
|
||||
private String type;
|
||||
private String name;
|
||||
private String parentId;
|
||||
private Integer order;
|
||||
private Long connectionId;
|
||||
private Boolean expanded;
|
||||
private Long createdAt;
|
||||
private Long updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.sshmanager.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "session_tree_layouts")
|
||||
public class SessionTreeLayout {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private Long userId;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "CLOB")
|
||||
private String layoutJson;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant updatedAt = Instant.now();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.sshmanager.repository;
|
||||
|
||||
import com.sshmanager.entity.SessionTreeLayout;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SessionTreeLayoutRepository extends JpaRepository<SessionTreeLayout, Long> {
|
||||
|
||||
Optional<SessionTreeLayout> findByUserId(Long userId);
|
||||
}
|
||||
@@ -100,15 +100,17 @@ public class ConnectionService {
|
||||
return ConnectionDto.fromEntity(conn);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new RuntimeException("Connection not found: " + id));
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
connectionRepository.delete(conn);
|
||||
}
|
||||
@Transactional
|
||||
public void delete(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElse(null);
|
||||
if (conn == null) {
|
||||
return;
|
||||
}
|
||||
if (!conn.getUserId().equals(userId)) {
|
||||
throw new RuntimeException("Access denied");
|
||||
}
|
||||
connectionRepository.delete(conn);
|
||||
}
|
||||
|
||||
public Connection getConnectionForSsh(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sshmanager.dto.SessionTreeLayoutDto;
|
||||
import com.sshmanager.entity.SessionTreeLayout;
|
||||
import com.sshmanager.repository.SessionTreeLayoutRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
|
||||
@Service
|
||||
public class SessionTreeLayoutService {
|
||||
|
||||
private final SessionTreeLayoutRepository sessionTreeLayoutRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SessionTreeLayoutService(SessionTreeLayoutRepository sessionTreeLayoutRepository,
|
||||
ObjectMapper objectMapper) {
|
||||
this.sessionTreeLayoutRepository = sessionTreeLayoutRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SessionTreeLayoutDto getLayout(Long userId) {
|
||||
SessionTreeLayout layout = sessionTreeLayoutRepository.findByUserId(userId).orElse(null);
|
||||
if (layout == null || layout.getLayoutJson() == null || layout.getLayoutJson().trim().isEmpty()) {
|
||||
return createEmptyLayout();
|
||||
}
|
||||
|
||||
try {
|
||||
SessionTreeLayoutDto parsed = objectMapper.readValue(layout.getLayoutJson(), SessionTreeLayoutDto.class);
|
||||
if (parsed.getNodes() == null) {
|
||||
parsed.setNodes(new ArrayList<>());
|
||||
}
|
||||
return parsed;
|
||||
} catch (Exception e) {
|
||||
return createEmptyLayout();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SessionTreeLayoutDto saveLayout(Long userId, SessionTreeLayoutDto request) {
|
||||
SessionTreeLayoutDto payload = request == null ? createEmptyLayout() : request;
|
||||
if (payload.getNodes() == null) {
|
||||
payload.setNodes(new ArrayList<>());
|
||||
}
|
||||
|
||||
final String layoutJson;
|
||||
try {
|
||||
layoutJson = objectMapper.writeValueAsString(payload);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to serialize session tree layout", e);
|
||||
}
|
||||
|
||||
SessionTreeLayout layout = sessionTreeLayoutRepository.findByUserId(userId).orElseGet(SessionTreeLayout::new);
|
||||
layout.setUserId(userId);
|
||||
layout.setLayoutJson(layoutJson);
|
||||
layout.setUpdatedAt(Instant.now());
|
||||
sessionTreeLayoutRepository.save(layout);
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private SessionTreeLayoutDto createEmptyLayout() {
|
||||
return new SessionTreeLayoutDto(new ArrayList<>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.dto.SessionTreeLayoutDto;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.SessionTreeLayoutService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SessionTreeLayoutControllerTest {
|
||||
|
||||
@Mock
|
||||
private SessionTreeLayoutService sessionTreeLayoutService;
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@InjectMocks
|
||||
private SessionTreeLayoutController sessionTreeLayoutController;
|
||||
|
||||
private Authentication authentication;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
authentication = mock(Authentication.class);
|
||||
when(authentication.getName()).thenReturn("testuser");
|
||||
|
||||
User user = new User();
|
||||
user.setId(1L);
|
||||
when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLayoutUsesCurrentUserId() {
|
||||
SessionTreeLayoutDto expected = new SessionTreeLayoutDto();
|
||||
when(sessionTreeLayoutService.getLayout(1L)).thenReturn(expected);
|
||||
|
||||
ResponseEntity<SessionTreeLayoutDto> response = sessionTreeLayoutController.getLayout(authentication);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertEquals(expected, response.getBody());
|
||||
verify(sessionTreeLayoutService).getLayout(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLayoutUsesCurrentUserId() {
|
||||
SessionTreeLayoutDto request = new SessionTreeLayoutDto();
|
||||
when(sessionTreeLayoutService.saveLayout(1L, request)).thenReturn(request);
|
||||
|
||||
ResponseEntity<SessionTreeLayoutDto> response = sessionTreeLayoutController.saveLayout(request, authentication);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertEquals(request, response.getBody());
|
||||
verify(sessionTreeLayoutService).saveLayout(1L, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sshmanager.dto.SessionTreeLayoutDto;
|
||||
import com.sshmanager.dto.SessionTreeNodeDto;
|
||||
import com.sshmanager.entity.SessionTreeLayout;
|
||||
import com.sshmanager.repository.SessionTreeLayoutRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SessionTreeLayoutServiceTest {
|
||||
|
||||
@Mock
|
||||
private SessionTreeLayoutRepository sessionTreeLayoutRepository;
|
||||
|
||||
@Mock
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@InjectMocks
|
||||
private SessionTreeLayoutService sessionTreeLayoutService;
|
||||
|
||||
@Test
|
||||
void getLayoutReturnsEmptyWhenNoSavedData() {
|
||||
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
|
||||
|
||||
SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L);
|
||||
|
||||
assertTrue(result.getNodes().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLayoutParsesSavedJson() throws Exception {
|
||||
SessionTreeLayout saved = new SessionTreeLayout();
|
||||
saved.setUserId(1L);
|
||||
saved.setLayoutJson("{\"nodes\":[{\"id\":\"n1\"}]}");
|
||||
|
||||
SessionTreeLayoutDto parsed = new SessionTreeLayoutDto(Arrays.asList(
|
||||
new SessionTreeNodeDto("n1", "folder", "我的连接", null, 0, null, true, 1L, 1L)
|
||||
));
|
||||
|
||||
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.of(saved));
|
||||
when(objectMapper.readValue(saved.getLayoutJson(), SessionTreeLayoutDto.class)).thenReturn(parsed);
|
||||
|
||||
SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L);
|
||||
|
||||
assertEquals(1, result.getNodes().size());
|
||||
assertEquals("n1", result.getNodes().get(0).getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLayoutNormalizesNullNodes() throws Exception {
|
||||
SessionTreeLayoutDto request = new SessionTreeLayoutDto();
|
||||
request.setNodes(null);
|
||||
|
||||
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
|
||||
when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}");
|
||||
|
||||
SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, request);
|
||||
|
||||
assertTrue(result.getNodes().isEmpty());
|
||||
ArgumentCaptor<SessionTreeLayout> captor = ArgumentCaptor.forClass(SessionTreeLayout.class);
|
||||
verify(sessionTreeLayoutRepository).save(captor.capture());
|
||||
assertEquals(1L, captor.getValue().getUserId().longValue());
|
||||
assertEquals("{\"nodes\":[]}", captor.getValue().getLayoutJson());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLayoutCreatesEmptyPayloadWhenRequestIsNull() throws Exception {
|
||||
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
|
||||
when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}");
|
||||
|
||||
SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, null);
|
||||
|
||||
assertEquals(Collections.emptyList(), result.getNodes());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user