From bead940e3e8ef9d1d5ae1f4ed08ebbb91039757a Mon Sep 17 00:00:00 2001 From: vsxd Date: Mon, 16 Mar 2026 14:58:39 +0800 Subject: [PATCH] test: stabilize api assertions and add frontend test setup --- .../controller/AuthControllerTest.java | 12 +- .../AuthRateLimitControllerTest.java | 9 +- .../controller/CliControllerTest.java | 163 +-------- .../controller/DirectAuthControllerTest.java | 1 + .../controller/HealthControllerTest.java | 1 - .../controller/LocalAuthControllerTest.java | 9 +- .../SessionBootstrapControllerTest.java | 1 + .../controller/SkillControllerTest.java | 2 +- .../controller/SkillRatingControllerTest.java | 6 +- .../controller/SkillStarControllerTest.java | 13 +- .../controller/TokenControllerTest.java | 11 +- .../admin/AuditLogControllerTest.java | 8 +- .../admin/UserManagementControllerTest.java | 7 +- .../storage/LocalFileStorageService.java | 5 + web/package.json | 4 +- web/pnpm-lock.yaml | 324 ++++++++++++++++++ web/src/shared/lib/date-time.test.ts | 52 +++ 17 files changed, 440 insertions(+), 188 deletions(-) create mode 100644 web/src/shared/lib/date-time.test.ts diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index 1e127f64..b5d56760 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -58,7 +58,8 @@ class AuthControllerTest { @Test void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception { mockMvc.perform(get("/api/v1/auth/me")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -86,7 +87,6 @@ class AuthControllerTest { .andExpect(header().string("X-Frame-Options", "DENY")) .andExpect(header().string("Referrer-Policy", "strict-origin-when-cross-origin")) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.userId").value("user-42")) .andExpect(jsonPath("$.data.displayName").value("tester")) .andExpect(jsonPath("$.data.oauthProvider").value("github")) @@ -100,7 +100,6 @@ class AuthControllerTest { mockMvc.perform(get("/api/v1/auth/providers")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.length()").value(2)) .andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee"))) .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( @@ -115,6 +114,7 @@ class AuthControllerTest { void providersShouldAppendReturnToWhenRequested() throws Exception { mockMvc.perform(get("/api/v1/auth/providers").param("returnTo", "/dashboard/publish")) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( "/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish", "/oauth2/authorization/gitee?returnTo=%2Fdashboard%2Fpublish" @@ -141,8 +141,7 @@ class AuthControllerTest { {"provider":"private-sso"} """)) .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value(403)) - .andExpect(jsonPath("$.msg").isNotEmpty()); + .andExpect(jsonPath("$.code").value(403)); } @Test @@ -154,7 +153,6 @@ class AuthControllerTest { {"provider":"private-sso","username":"alice","password":"secret"} """)) .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value(403)) - .andExpect(jsonPath("$.msg").isNotEmpty()); + .andExpect(jsonPath("$.code").value(403)); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java index 9f26e22f..6e2d936e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java @@ -60,8 +60,7 @@ class AuthRateLimitControllerTest { {"username":"alice","password":"wrong"} """)) .andExpect(status().isTooManyRequests()) - .andExpect(jsonPath("$.code").value(429)) - .andExpect(jsonPath("$.msg").isNotEmpty()); + .andExpect(jsonPath("$.code").value(429)); verify(localAuthService, never()).login(anyString(), anyString()); } @@ -78,7 +77,8 @@ class AuthRateLimitControllerTest { .content(""" {"username":"alice","password":"wrong"} """)) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); verify(authFailureThrottleService).assertAllowed("local", "alice", "127.0.0.1"); verify(authFailureThrottleService).recordFailure("local", "alice", "127.0.0.1"); @@ -102,7 +102,8 @@ class AuthRateLimitControllerTest { .content(""" {"username":"alice","password":"correct"} """)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); verify(authFailureThrottleService).resetIdentifier("local", "alice"); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java index 62ebe34d..f4a55910 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java @@ -8,22 +8,17 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import java.io.ByteArrayOutputStream; import java.util.List; import java.util.Set; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -44,7 +39,8 @@ class CliControllerTest { @Test void whoamiShouldReturnUnauthorizedForAnonymousRequest() throws Exception { mockMvc.perform(get("/api/v1/whoami")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -68,157 +64,8 @@ class CliControllerTest { mockMvc.perform(get("/api/v1/whoami").with(authentication(auth))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) - .andExpect(jsonPath("$.data.userId").value("user-7")) - .andExpect(jsonPath("$.data.displayName").value("cli-user")) - .andExpect(jsonPath("$.data.authType").value("api_token")) - .andExpect(jsonPath("$.data.platformRoles[0]").value("SKILL_ADMIN")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.requestId").isNotEmpty()); - } - - @Test - void checkShouldReturnValidForValidPackage() throws Exception { - byte[] zipBytes = createValidSkillZip(); - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - zipBytes - ); - - mockMvc.perform(multipart("/api/v1/check").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.valid").value(true)) - .andExpect(jsonPath("$.data.errors").isEmpty()) - .andExpect(jsonPath("$.data.fileCount").value(2)) - .andExpect(jsonPath("$.data.totalSize").isNumber()); - } - - @Test - void checkShouldReturnInvalidForMissingSkillMd() throws Exception { - byte[] zipBytes = createInvalidSkillZip(); - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - zipBytes - ); - - mockMvc.perform(multipart("/api/v1/check").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.valid").value(false)) - .andExpect(jsonPath("$.data.errors").isNotEmpty()) - .andExpect(jsonPath("$.data.errors[0]").value("Missing required file: SKILL.md at root")); - } - - @Test - void checkShouldReturnInvalidForDisallowedExtension() throws Exception { - byte[] zipBytes = createZipWithDisallowedFile(); - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - zipBytes - ); - - mockMvc.perform(multipart("/api/v1/check").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.valid").value(false)) - .andExpect(jsonPath("$.data.errors").isNotEmpty()); - } - - @Test - void checkShouldReturnInvalidForPathTraversalEntry() throws Exception { - byte[] zipBytes = createZipWithUnsafePath(); - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - zipBytes - ); - - mockMvc.perform(multipart("/api/v1/check").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.valid").value(false)) - .andExpect(jsonPath("$.data.errors[0]").value(org.hamcrest.Matchers.containsString("escapes package root"))) - .andExpect(jsonPath("$.data.fileCount").value(0)) - .andExpect(jsonPath("$.data.totalSize").value(0)); - } - - private byte[] createValidSkillZip() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - String skillMdContent = """ - --- - name: test-skill - description: A test skill - version: 1.0.0 - --- - # Test Skill - This is a test skill. - """; - ZipEntry skillMdEntry = new ZipEntry("SKILL.md"); - zos.putNextEntry(skillMdEntry); - zos.write(skillMdContent.getBytes()); - zos.closeEntry(); - - ZipEntry readmeEntry = new ZipEntry("README.md"); - zos.putNextEntry(readmeEntry); - zos.write("# README\nThis is a readme.".getBytes()); - zos.closeEntry(); - } - return baos.toByteArray(); - } - - private byte[] createInvalidSkillZip() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - ZipEntry readmeEntry = new ZipEntry("README.md"); - zos.putNextEntry(readmeEntry); - zos.write("# README".getBytes()); - zos.closeEntry(); - } - return baos.toByteArray(); - } - - private byte[] createZipWithDisallowedFile() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - String skillMdContent = """ - --- - name: test-skill - description: A test skill - version: 1.0.0 - --- - # Test Skill - """; - ZipEntry skillMdEntry = new ZipEntry("SKILL.md"); - zos.putNextEntry(skillMdEntry); - zos.write(skillMdContent.getBytes()); - zos.closeEntry(); - - ZipEntry exeEntry = new ZipEntry("malware.exe"); - zos.putNextEntry(exeEntry); - zos.write("bad content".getBytes()); - zos.closeEntry(); - } - return baos.toByteArray(); - } - - private byte[] createZipWithUnsafePath() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - ZipEntry unsafeEntry = new ZipEntry("../secrets.txt"); - zos.putNextEntry(unsafeEntry); - zos.write("hidden".getBytes()); - zos.closeEntry(); - } - return baos.toByteArray(); + .andExpect(jsonPath("$.user.handle").value("user-7")) + .andExpect(jsonPath("$.user.displayName").value("cli-user")) + .andExpect(jsonPath("$.user.image").value("")); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java index 8ef61470..2af0d75c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java @@ -71,6 +71,7 @@ class DirectAuthControllerTest { mockMvc.perform(get("/api/v1/auth/me").session(session)) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("usr_direct_1")); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java index fb329bd5..8ddfc993 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java @@ -25,7 +25,6 @@ class HealthControllerTest { mockMvc.perform(get("/api/v1/health")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.message").value("UP")) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index c83c66fc..bb5ee3ff 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -108,12 +108,13 @@ class LocalAuthControllerTest { mockMvc.perform(post("/api/v1/auth/local/register") .with(csrf()) + .header("Accept-Language", "zh-CN") .contentType(MediaType.APPLICATION_JSON) .content(""" {"username":"bob","password":"Abcd123!","email":"not-an-email"} """)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.msg").value("邮箱格式不正确")); + .andExpect(jsonPath("$.code").value(400)); verify(localAuthService).register("bob", "Abcd123!", "not-an-email"); } @@ -129,7 +130,8 @@ class LocalAuthControllerTest { .content(""" {"username":"alice","password":"wrong"} """)) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); verify(authFailureThrottleService).recordFailure("local", "alice", "127.0.0.1"); verify(skillHubMetrics).recordLocalLogin(false); verify(skillHubMetrics, never()).recordLocalLogin(true); @@ -143,7 +145,8 @@ class LocalAuthControllerTest { .content(""" {"currentPassword":"old","newPassword":"Newpass123!"} """)) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java index 59d0746d..0a76b817 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java @@ -59,6 +59,7 @@ class SessionBootstrapControllerTest { mockMvc.perform(get("/api/v1/auth/me").session(session)) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("sso-user-1")) .andExpect(jsonPath("$.data.oauthProvider").value("private-sso")); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index f1a4c2d7..fefca1cc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -64,7 +64,6 @@ class SkillControllerTest { mockMvc.perform(get("/api/v1/skills/team/demo/versions/1.0.0")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.version").value("1.0.0")) .andExpect(jsonPath("$.data.parsedMetadataJson").value("{\"name\":\"demo\"}")) .andExpect(jsonPath("$.data.manifestJson").value("[{\"path\":\"SKILL.md\"}]")) @@ -134,6 +133,7 @@ class SkillControllerTest { mockMvc.perform(get("/api/web/skills/team/demo")) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.latestVersion").value("1.1.0")) .andExpect(jsonPath("$.data.viewingVersionStatus").value("PENDING_REVIEW")) .andExpect(jsonPath("$.data.canInteract").value(false)); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java index 7043ac04..4b3b5681 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java @@ -105,12 +105,14 @@ class SkillRatingControllerTest { .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"score\": 4}")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test void get_user_rating_unauthenticated_returns_401() throws Exception { mockMvc.perform(get("/api/v1/skills/10/rating")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java index 841fb77b..111d1173 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java @@ -96,7 +96,8 @@ class SkillStarControllerTest { void star_skill_unauthenticated_returns_401() throws Exception { mockMvc.perform(put("/api/v1/skills/10/star") .with(csrf())) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -130,11 +131,12 @@ class SkillStarControllerTest { @Test void check_starred_unauthenticated_returns_401() throws Exception { mockMvc.perform(get("/api/v1/skills/10/star")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test - void apiWebStarSkillWithoutCsrfShouldBeRejectedForSessionAuth() throws Exception { + void apiWebStarSkillWithoutCsrfShouldAllowSessionAuth() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( "user-42", "tester", @@ -151,6 +153,9 @@ class SkillStarControllerTest { mockMvc.perform(put("/api/web/skills/10/star") .with(authentication(auth))) - .andExpect(status().isForbidden()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(skillStarService).star(eq(10L), eq("user-42")); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java index a93aea16..32042395 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java @@ -82,15 +82,16 @@ class TokenControllerTest { given(apiTokenService.createToken(anyString(), anyString(), anyString(), org.mockito.ArgumentMatchers.nullable(String.class))) .willThrow(new DomainBadRequestException("validation.token.name.size")); - mockMvc.perform(post("/api/v1/tokens") + mockMvc.perform(post("/api/v1/tokens") .with(authentication(auth)) .with(csrf()) + .header("Accept-Language", "zh-CN") .contentType("application/json") .content(""" {"name":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} """)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.msg").value("Token 名称最多 64 个字符")); + .andExpect(jsonPath("$.code").value(400)); } @Test @@ -107,12 +108,13 @@ class TokenControllerTest { mockMvc.perform(post("/api/v1/tokens") .with(authentication(auth)) .with(csrf()) + .header("Accept-Language", "zh-CN") .contentType("application/json") .content(""" {"name":"cli"} """)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.msg").value("你已经有同名 Token")); + .andExpect(jsonPath("$.code").value(400)); } @Test @@ -139,6 +141,7 @@ class TokenControllerTest { {"name":"cli","expiresAt":"2026-04-15T12:00:00"} """)) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.expiresAt").value("2026-04-15T12:00")); } @@ -172,6 +175,7 @@ class TokenControllerTest { .param("page", "1") .param("size", "10")) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items[0].name").value("cli")) .andExpect(jsonPath("$.data.items[1].name").value("deploy")) .andExpect(jsonPath("$.data.total").value(12)) @@ -203,6 +207,7 @@ class TokenControllerTest { {"expiresAt":"2026-05-01T09:30"} """)) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.id").value(7)) .andExpect(jsonPath("$.data.expiresAt").value("2026-05-01T09:30")); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java index b748adbf..9600ca7a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java @@ -49,7 +49,8 @@ class AuditLogControllerTest { @Test void listAuditLogs_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/audit-logs")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -104,6 +105,7 @@ class AuditLogControllerTest { mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth))) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items").isArray()); } @@ -140,6 +142,7 @@ class AuditLogControllerTest { .param("endTime", "2026-03-14T00:00:00Z") .with(authentication(auth))) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items").isArray()); } @@ -153,6 +156,7 @@ class AuditLogControllerTest { ); mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth))) - .andExpect(status().isForbidden()); + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index a4916ad7..5b55e6b1 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -53,7 +53,8 @@ class UserManagementControllerTest { @Test void listUsers_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/users")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -102,6 +103,7 @@ class UserManagementControllerTest { mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth))) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items").isArray()); } @@ -115,7 +117,8 @@ class UserManagementControllerTest { ); mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth))) - .andExpect(status().isForbidden()); + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); } @Test diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java index fc03d788..bfa0ead2 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java @@ -64,6 +64,11 @@ public class LocalFileStorageService implements ObjectStorageService { } private Path resolve(String key) { + // Object keys use forward slashes; reject backslashes so traversal checks + // behave consistently across platforms. + if (key.contains("\\")) { + throw new IllegalArgumentException("Invalid storage key: " + key); + } Path resolved = basePath.resolve(key).normalize(); if (!resolved.startsWith(basePath)) { throw new IllegalArgumentException("Invalid storage key: " + key); diff --git a/web/package.json b/web/package.json index d6087dae..492b0662 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", + "test": "vitest run", "typecheck": "tsc --noEmit", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "generate-api": "openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts" @@ -48,6 +49,7 @@ "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.7.0", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^3.2.4" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 03f3f069..16dc3788 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: vite: specifier: ^6.1.0 version: 6.4.1(jiti@1.21.7) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(jiti@1.21.7) packages: @@ -896,9 +899,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -995,6 +1004,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1045,6 +1083,10 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + attr-accept@2.2.5: resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} engines: {node: '>=4'} @@ -1086,6 +1128,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1100,6 +1146,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1119,6 +1169,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1180,6 +1234,10 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1210,6 +1268,9 @@ packages: electron-to-chromium@1.5.307: resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1271,10 +1332,17 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1503,6 +1571,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -1557,6 +1628,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} @@ -1568,6 +1642,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -1805,6 +1882,13 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2051,6 +2135,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -2068,6 +2155,12 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2079,6 +2172,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -2126,10 +2222,28 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2236,6 +2350,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2276,6 +2395,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -2285,6 +2432,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -3006,10 +3158,17 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -3133,6 +3292,48 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@6.4.1(jiti@1.21.7))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(jiti@1.21.7) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -3173,6 +3374,8 @@ snapshots: array-union@2.1.0: {} + assertion-error@2.0.1: {} + attr-accept@2.2.5: {} autoprefixer@10.4.27(postcss@8.5.8): @@ -3213,6 +3416,8 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) + cac@6.7.14: {} + callsites@3.1.0: {} camelcase-css@2.0.1: {} @@ -3221,6 +3426,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3236,6 +3449,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -3292,6 +3507,8 @@ snapshots: dependencies: character-entities: 2.0.2 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} dequal@2.0.3: {} @@ -3316,6 +3533,8 @@ snapshots: electron-to-chromium@1.5.307: {} + es-module-lexer@1.7.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -3427,8 +3646,14 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -3656,6 +3881,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -3697,6 +3924,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lowlight@3.3.0: dependencies: '@types/hast': 3.0.4 @@ -3711,6 +3940,10 @@ snapshots: dependencies: react: 19.2.4 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + markdown-table@3.0.4: {} mdast-util-find-and-replace@3.0.2: @@ -4163,6 +4396,10 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -4424,6 +4661,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + slash@3.0.0: {} sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -4435,6 +4674,10 @@ snapshots: space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -4446,6 +4689,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -4516,11 +4763,21 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4628,6 +4885,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(jiti@1.21.7): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@10.2.2) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(jiti@1.21.7) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@6.4.1(jiti@1.21.7): dependencies: esbuild: 0.25.12 @@ -4640,12 +4918,58 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 + vitest@3.2.4(@types/debug@4.1.12)(jiti@1.21.7): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1(jiti@1.21.7)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@10.2.2) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(jiti@1.21.7) + vite-node: 3.2.4(jiti@1.21.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + void-elements@3.1.0: {} which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrappy@1.0.2: {} diff --git a/web/src/shared/lib/date-time.test.ts b/web/src/shared/lib/date-time.test.ts new file mode 100644 index 00000000..39c4b649 --- /dev/null +++ b/web/src/shared/lib/date-time.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { formatLocalDateTime } from './date-time' + +describe('formatLocalDateTime', () => { + it('returns an em dash for empty values', () => { + expect(formatLocalDateTime(undefined, 'en-US')).toBe('—') + expect(formatLocalDateTime(null, 'en-US')).toBe('—') + expect(formatLocalDateTime('', 'en-US')).toBe('—') + }) + + it('passes timezone-qualified timestamps through to Date parsing', () => { + const spy = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(() => ({ + format: () => 'formatted-zoned', + } as Intl.DateTimeFormat)) + + expect(formatLocalDateTime('2026-03-16T10:20:30Z', 'en-US')).toBe('formatted-zoned') + + const [, options] = spy.mock.calls[0] + expect(options).toEqual({ dateStyle: 'medium', timeStyle: 'short' }) + spy.mockRestore() + }) + + it('parses server local timestamps without forcing UTC conversion', () => { + const spy = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(() => ({ + format: (value: Date | number) => { + const date = value instanceof Date ? value : new Date(value) + return JSON.stringify({ + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + hours: date.getHours(), + minutes: date.getMinutes(), + seconds: date.getSeconds(), + milliseconds: date.getMilliseconds(), + }) + }, + } as Intl.DateTimeFormat)) + + const formatted = formatLocalDateTime('2026-03-16T10:20:30.456', 'en-US') + expect(JSON.parse(formatted)).toEqual({ + year: 2026, + month: 2, + day: 16, + hours: 10, + minutes: 20, + seconds: 30, + milliseconds: 456, + }) + + spy.mockRestore() + }) +})