diff --git a/scripts/namespace-smoke-test.sh b/scripts/namespace-smoke-test.sh index ae2b7b59..838551cb 100755 --- a/scripts/namespace-smoke-test.sh +++ b/scripts/namespace-smoke-test.sh @@ -131,12 +131,18 @@ import sys slug = sys.argv[1] data = json.loads(os.environ["JSON_INPUT"]) items = data["data"] -raise SystemExit(0 if all(item["slug"] != slug for item in items) else 1) +match = next((item for item in items if item["slug"] == slug), None) +if not match: + raise SystemExit(1) +if match.get("currentUserRole") is not None: + raise SystemExit(2) +if match["status"] != "ACTIVE": + raise SystemExit(3) PY then - pass "Namespace is not visible to unrelated users in my namespaces" + pass "SUPER_ADMIN can see namespace without namespace membership" else - fail "Unrelated user should not see team namespace in my namespaces" + fail "SUPER_ADMIN should see team namespace in my namespaces without namespace role" fi FREEZE_FORBIDDEN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 981a4223..b8593149 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -68,7 +68,16 @@ public class NamespaceController extends BaseApiController { } @GetMapping("/me/namespaces") - public ApiResponse> listMyNamespaces( + public ApiResponse> listMyNamespaces( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { + return ok("response.success.read", + namespacePortalQueryAppService.listMyNamespaces(userNsRoles, normalizePlatformRoles(platformRoles))); + } + + @GetMapping("/me/namespaces/page") + public ApiResponse> listMyNamespacesPage( Pageable pageable, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 0be311fe..f2597960 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.MyNamespaceResponse; import com.iflytek.skillhub.dto.NamespaceResponse; import com.iflytek.skillhub.dto.PageResponse; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -115,11 +116,20 @@ public class NamespacePortalQueryAppService { @Transactional(readOnly = true) public List listMyNamespaces(Map userNamespaceRoles, Set platformRoles) { - return listMyNamespaces( - PageRequest.of(0, MAX_MY_NAMESPACE_PAGE_SIZE), - userNamespaceRoles, - platformRoles - ).items(); + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + if (namespaceRoles.isEmpty() && !isSuperAdmin(platformRoles)) { + return List.of(); + } + + List visibleNamespaces = isSuperAdmin(platformRoles) + ? listAllNamespacesByPage() + : namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() + .sorted(Comparator.comparing(Namespace::getSlug)) + .toList(); + + return visibleNamespaces.stream() + .map(namespace -> myNamespaceResponse(namespace, namespaceRoles)) + .toList(); } @Transactional(readOnly = true) @@ -213,6 +223,25 @@ public class NamespacePortalQueryAppService { namespaceService.canDelete(namespace, currentUserRole)); } + private List listAllNamespacesByPage() { + List namespaces = new ArrayList<>(); + int pageNumber = 0; + Page page; + do { + page = namespaceRepository.findAll(PageRequest.of( + pageNumber, + MAX_MY_NAMESPACE_PAGE_SIZE, + Sort.by(NAMESPACE_SLUG_SORT).ascending() + )); + namespaces.addAll(page.getContent()); + pageNumber++; + if (page.getContent().isEmpty()) { + break; + } + } while (!page.isLast() && namespaces.size() < page.getTotalElements()); + return namespaces; + } + private Pageable normalizeMyNamespacesPageable(Pageable pageable) { int page = pageable != null && pageable.isPaged() ? Math.max(pageable.getPageNumber(), 0) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 21c2c752..aa61ef26 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -100,9 +100,33 @@ class NamespacePortalControllerTest { } @Test - void listMyNamespaces_superAdminReturnsPagedNamespacesWithoutMembership() throws Exception { + void listMyNamespaces_superAdminKeepsLegacyArrayContract() throws Exception { + Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace archived = namespace(3L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + given(namespaceRepository.findAll(any())) + .willReturn(new org.springframework.data.domain.PageImpl<>( + List.of(active, archived), + org.springframework.data.domain.PageRequest.of(0, 2), + 2 + )); + + mockMvc.perform(get("/api/v1/me/namespaces") + .param("page", "0") + .param("size", "2") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].slug").value("active")) + .andExpect(jsonPath("$.data[0].currentUserRole").doesNotExist()) + .andExpect(jsonPath("$.data[1].slug").value("archived")) + .andExpect(jsonPath("$.data.items").doesNotExist()); + } + + @Test + void listMyNamespacesPage_superAdminReturnsPagedNamespacesWithoutMembership() throws Exception { Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); - Namespace frozen = namespace(2L, "frozen", NamespaceStatus.FROZEN, NamespaceType.TEAM); Namespace archived = namespace(3L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); given(namespaceRepository.findAll(any())) @@ -112,7 +136,7 @@ class NamespacePortalControllerTest { 3 )); - mockMvc.perform(get("/api/v1/me/namespaces") + mockMvc.perform(get("/api/web/me/namespaces/page") .param("page", "0") .param("size", "2") .with(auth("super-1", Set.of("SUPER_ADMIN"))) @@ -139,10 +163,10 @@ class NamespacePortalControllerTest { .requestAttr("userId", "owner-1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.items[0].slug").value("team-a")) - .andExpect(jsonPath("$.data.items[0].status").value("ARCHIVED")) - .andExpect(jsonPath("$.data.items[0].currentUserRole").value("OWNER")) - .andExpect(jsonPath("$.data.items[0].canDelete").value(false)); + .andExpect(jsonPath("$.data[0].slug").value("team-a")) + .andExpect(jsonPath("$.data[0].status").value("ARCHIVED")) + .andExpect(jsonPath("$.data[0].currentUserRole").value("OWNER")) + .andExpect(jsonPath("$.data[0].canDelete").value(false)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 7ef03e09..4567c015 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -145,6 +145,22 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.size()).isEqualTo(2); } + @Test + void listMyNamespaces_superAdminCompatibilityCollectsAllRepositoryPages() { + Namespace first = namespace(1L, "first"); + Namespace second = namespace(2L, "second"); + Namespace third = namespace(3L, "third"); + + when(namespaceRepository.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(first, second), PageRequest.of(0, 2), 3)) + .thenReturn(new PageImpl<>(List.of(third), PageRequest.of(1, 2), 3)); + + var response = service.listMyNamespaces(Map.of(), Set.of("SUPER_ADMIN")); + + assertThat(response).extracting("slug").containsExactly("first", "second", "third"); + assertThat(response).extracting("currentUserRole").containsOnlyNulls(); + } + @Test void getNamespace_throwsWhenCurrentUserIsNotNamespaceMember() { Namespace namespace = namespace(1L, "team-a"); diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 9735a039..71cd9ebd 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -44,13 +44,6 @@ interface NamespaceCandidate { status: string } -interface PageResponse { - items: T[] - total: number - page: number - size: number -} - interface ApiEnvelope { code: number msg: string @@ -256,10 +249,9 @@ export class E2eTestDataBuilder { } async listMyNamespaces(): Promise { - const page = await parseEnvelope>( - await this.request.get('/api/web/me/namespaces?page=0&size=100'), + return parseEnvelope( + await this.request.get('/api/web/me/namespaces'), ) - return page.items } private isTeamNamespace(namespace: SeededNamespace): boolean { diff --git a/web/e2e/my-namespaces-super-admin-actions.spec.ts b/web/e2e/my-namespaces-super-admin-actions.spec.ts index abb1e1f8..1f3fdccf 100644 --- a/web/e2e/my-namespaces-super-admin-actions.spec.ts +++ b/web/e2e/my-namespaces-super-admin-actions.spec.ts @@ -39,7 +39,7 @@ test.describe('My Namespaces super admin actions', () => { await page.route('**/api/web/skills/*/star', (route) => fulfillJson(route, false)) - await page.route('**/api/web/me/namespaces?**', (route) => fulfillJson(route, { + await page.route('**/api/web/me/namespaces/page?**', (route) => fulfillJson(route, { items: [ { id: 101, diff --git a/web/e2e/promotions-review.spec.ts b/web/e2e/promotions-review.spec.ts index 99e595b6..8030635b 100644 --- a/web/e2e/promotions-review.spec.ts +++ b/web/e2e/promotions-review.spec.ts @@ -74,7 +74,7 @@ test.describe('Promotion review dashboard', () => { }), }) }) - await page.route('**/api/web/me/namespaces?**', async (route) => { + await page.route('**/api/web/me/namespaces/page?**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 3bb7c2d3..7236f292 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -164,6 +164,46 @@ describe('namespaceApi.delete', () => { }) describe('namespaceApi.listMine', () => { + it('keeps the compatibility endpoint as a current user namespace array', async () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' } + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + msg: 'ok', + data: [{ + id: 1, + slug: 'team-a', + displayName: 'Team A', + type: 'TEAM', + status: 'ACTIVE', + createdAt: '2026-05-07T00:00:00Z', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: false, + }], + timestamp: '2026-05-07T00:00:00Z', + requestId: 'req-test', + }), + }) + vi.stubGlobal('fetch', fetchMock) + + const namespaces = await namespaceApi.listMine() + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/web/me/namespaces', + expect.objectContaining({ + headers: expect.any(Headers), + }), + ) + expect(namespaces).toEqual([expect.objectContaining({ slug: 'team-a' })]) + }) +}) + +describe('namespaceApi.listMinePage', () => { it('requests a bounded page of current user namespaces', async () => { window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' } const fetchMock = vi.fn().mockResolvedValue({ @@ -183,10 +223,10 @@ describe('namespaceApi.listMine', () => { }) vi.stubGlobal('fetch', fetchMock) - const page = await namespaceApi.listMine({ page: 2, size: 25 }) + const page = await namespaceApi.listMinePage({ page: 2, size: 25 }) expect(fetchMock).toHaveBeenCalledWith( - 'https://api.example.com/api/web/me/namespaces?page=2&size=25', + 'https://api.example.com/api/web/me/namespaces/page?page=2&size=25', expect.objectContaining({ headers: expect.any(Headers), }), diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 78256d13..463eb36b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -639,10 +639,14 @@ export const namespaceApi = { return namespace }, - async listMine(params: { page?: number; size?: number } = {}): Promise> { + async listMine(): Promise { + return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) + }, + + async listMinePage(params: { page?: number; size?: number } = {}): Promise> { const page = params.page ?? 0 const size = params.size ?? 20 - return fetchJson>(`${WEB_API_PREFIX}/me/namespaces?page=${page}&size=${size}`) + return fetchJson>(`${WEB_API_PREFIX}/me/namespaces/page?page=${page}&size=${size}`) }, async getDetail(slug: string): Promise { diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 0a596131..4d9642a5 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -2660,6 +2660,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/me/namespaces/page": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyNamespacesPage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/me/namespaces/page": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyNamespacesPage_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/me/namespaces": { parameters: { query?: never; @@ -4557,6 +4589,15 @@ export interface components { /** Format: int32 */ size?: number; }; + ApiResponseListMyNamespaceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MyNamespaceResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ApiResponseGovernanceSummaryResponse: { /** Format: int32 */ code?: number; @@ -10008,7 +10049,7 @@ export interface operations { }; }; }; - listMyNamespaces: { + listMyNamespacesPage: { parameters: { query: { pageable: components["schemas"]["Pageable"]; @@ -10030,7 +10071,7 @@ export interface operations { }; }; }; - listMyNamespaces_1: { + listMyNamespacesPage_1: { parameters: { query: { pageable: components["schemas"]["Pageable"]; @@ -10052,6 +10093,46 @@ export interface operations { }; }; }; + listMyNamespaces: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListMyNamespaceResponse"]; + }; + }; + }; + }; + listMyNamespaces_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListMyNamespaceResponse"]; + }; + }; + }; + }; listVisibleLabels: { parameters: { query?: never; diff --git a/web/src/pages/dashboard/my-namespaces.test.ts b/web/src/pages/dashboard/my-namespaces.test.ts index 257fd183..e57790a9 100644 --- a/web/src/pages/dashboard/my-namespaces.test.ts +++ b/web/src/pages/dashboard/my-namespaces.test.ts @@ -103,7 +103,7 @@ vi.mock('@/shared/lib/toast', () => ({ })) import { MyNamespacesPage } from './my-namespaces' -import { executeNamespaceAction, resolveNamespaceActionCopy } from './my-namespaces' +import { executeNamespaceAction, resolveNamespaceActionCopy, resolveValidNamespacePage } from './my-namespaces' function buildNamespace(overrides: Partial = {}): ManagedNamespace { return { @@ -205,6 +205,12 @@ describe('MyNamespacesPage', () => { expect(html).toContain('pagination.next') }) + it('backs up to the last valid page when a delete empties the current page', () => { + expect(resolveValidNamespacePage(2, 40, 20)).toBe(1) + expect(resolveValidNamespacePage(1, 40, 20)).toBe(1) + expect(resolveValidNamespacePage(1, 0, 20)).toBe(0) + }) + it('routes delete actions to the delete mutation and emits success feedback', async () => { const t = (key: string) => key const copy = resolveNamespaceActionCopy(t, 'delete', 'Team ML') diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index c4a8e93c..4b59e2b0 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' @@ -142,6 +142,12 @@ export async function executeNamespaceAction( } } +export function resolveValidNamespacePage(currentPage: number, total: number, size: number) { + const safeSize = Math.max(size, 1) + const lastPage = Math.max(Math.ceil(total / safeSize) - 1, 0) + return Math.min(Math.max(currentPage, 0), lastPage) +} + /** * Dashboard page for namespaces the current user can manage or review. It owns * namespace lifecycle actions because each action combines permissions, copy, @@ -163,6 +169,16 @@ export function MyNamespacesPage() { const namespaces = namespacePage?.items ?? [] const totalPages = namespacePage ? Math.max(Math.ceil(namespacePage.total / namespacePage.size), 1) : 1 + useEffect(() => { + if (!namespacePage) { + return + } + const validPage = resolveValidNamespacePage(page, namespacePage.total, namespacePage.size) + if (validPage !== page) { + setPage(validPage) + } + }, [namespacePage, page]) + const handleNamespaceClick = (slug: string) => { navigate({ to: `/space/${encodeURIComponent(slug)}` }) } diff --git a/web/src/shared/hooks/use-namespace-queries.test.ts b/web/src/shared/hooks/use-namespace-queries.test.ts index b031b9e8..42808b0b 100644 --- a/web/src/shared/hooks/use-namespace-queries.test.ts +++ b/web/src/shared/hooks/use-namespace-queries.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const useQueryMock = vi.hoisted(() => vi.fn()) +const listMinePageMock = vi.hoisted(() => vi.fn()) vi.mock('@tanstack/react-query', () => ({ useQuery: useQueryMock, @@ -11,6 +12,7 @@ vi.mock('@tanstack/react-query', () => ({ vi.mock('@/api/client', () => ({ namespaceApi: { listMine: vi.fn(), + listMinePage: listMinePageMock, }, })) @@ -26,6 +28,7 @@ vi.mock('@/api/client', () => ({ describe('use-namespace-queries exports', () => { beforeEach(() => { useQueryMock.mockClear() + listMinePageMock.mockReset() }) it('exports all expected hook functions', async () => { @@ -65,4 +68,21 @@ describe('use-namespace-queries exports', () => { queryKey: ['namespaces', 'my', { page: 3, size: 15 }], })) }) + + it('fetches every page for compatibility consumers instead of truncating after the first page', async () => { + const firstPageItems = Array.from({ length: 100 }, (_, index) => ({ id: index + 1, slug: `team-${index + 1}` })) + listMinePageMock + .mockResolvedValueOnce({ items: firstPageItems, total: 101, page: 0, size: 100 }) + .mockResolvedValueOnce({ items: [{ id: 101, slug: 'team-101' }], total: 101, page: 1, size: 100 }) + const mod = await import('./use-namespace-queries') + + mod.useMyNamespaces() + const queryOptions = useQueryMock.mock.calls[useQueryMock.mock.calls.length - 1]?.[0] + const result = await queryOptions.queryFn() + + expect(listMinePageMock).toHaveBeenNthCalledWith(1, { page: 0, size: 100 }) + expect(listMinePageMock).toHaveBeenNthCalledWith(2, { page: 1, size: 100 }) + expect(result).toHaveLength(101) + expect(result[result.length - 1]).toEqual({ id: 101, slug: 'team-101' }) + }) }) diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index d48bc215..26ca5e8f 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -8,12 +8,25 @@ const MY_NAMESPACES_PAGE_SIZE = 20 const MY_NAMESPACES_COMPAT_SIZE = 100 async function getMyNamespaces(): Promise { - const page = await namespaceApi.listMine({ page: 0, size: MY_NAMESPACES_COMPAT_SIZE }) - return page.items + const namespaces: ManagedNamespace[] = [] + let page = 0 + let total = Number.POSITIVE_INFINITY + + while (namespaces.length < total) { + const response = await namespaceApi.listMinePage({ page, size: MY_NAMESPACES_COMPAT_SIZE }) + namespaces.push(...response.items) + total = response.total + page += 1 + if (response.items.length === 0) { + break + } + } + + return namespaces } async function getMyNamespacesPage(page = 0, size = MY_NAMESPACES_PAGE_SIZE): Promise> { - return namespaceApi.listMine({ page, size }) + return namespaceApi.listMinePage({ page, size }) } async function createNamespace(request: CreateNamespaceRequest): Promise {