diff --git a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts index 980c5335d5..c3958e5f40 100644 --- a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -1,4 +1,5 @@ import type { ConfigType } from '@nestjs/config' +import type { HarborRobot } from './registry-client.service' import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' @@ -92,4 +93,112 @@ describe('registryService', () => { expect(res).toMatchObject({ status: HttpStatus.OK, data: { project_id: 123 } }) }) + + describe('error and edge contracts', () => { + it('returns non-2xx statuses as data without throwing (caller-decides contract)', async () => { + server.use( + http.post(`${harborUrl}/api/v2.0/projects`, () => + HttpResponse.json({ message: 'conflict: project name already exists' }, { status: HttpStatus.CONFLICT })), + ) + + const res = await service.createProject('myproj', -1) + expect(res.status).toBe(HttpStatus.CONFLICT) + expect(res.data).toMatchObject({ message: expect.stringContaining('already exists') }) + }) + + it('propagates a 500 body through the caller-decides contract', async () => { + server.use( + http.get(`${harborUrl}/api/v2.0/projects/:projectName`, () => + HttpResponse.json({ errors: [{ code: 'INTERNAL' }] }, { status: HttpStatus.INTERNAL_SERVER_ERROR })), + ) + + const res = await service.getProjectByName('myproj') + expect(res.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR) + }) + + it('treats 204 No Content as null data', async () => { + server.use( + http.delete(`${harborUrl}/api/v2.0/projects/:projectName`, () => new Response(null, { status: HttpStatus.NO_CONTENT })), + ) + + const res = await service.deleteProjectByName('myproj') + expect(res).toEqual({ status: HttpStatus.NO_CONTENT, data: null }) + }) + + it('falls back to text parsing when content-type is not JSON', async () => { + server.use( + http.get(`${harborUrl}/api/v2.0/quotas`, () => + new Response('plain text error', { status: HttpStatus.OK, headers: { 'content-type': 'text/plain' } })), + ) + + const res = await service.listQuotas(123) + expect(res.data).toBe('plain text error') + }) + }) + + describe('paginate', () => { + it('yields robots across pages until a short page ends iteration', async () => { + const seenPages: string[] = [] + server.use( + http.get(`${harborUrl}/api/v2.0/robots`, ({ request }) => { + const page = Number(new URL(request.url).searchParams.get('page')) + seenPages.push(String(page)) + // page size is ROBOT_LIST_PAGE_SIZE=100: full page → continue, short page → stop + const rows = page === 1 + ? Array.from({ length: 100 }, (_, i) => ({ id: i + 1, name: `robot${i}` })) + : [{ id: 99, name: 'robot-last' }] + return HttpResponse.json(rows, { status: HttpStatus.OK }) + }), + ) + + const robots: HarborRobot[] = [] + for await (const robot of service.getProjectRobots(42)) robots.push(robot) + + expect(seenPages).toEqual(['1', '2']) + expect(robots).toHaveLength(101) + expect(robots.at(-1)).toMatchObject({ id: 99 }) + }) + + it('stops immediately on an empty first page', async () => { + let callCount = 0 + server.use( + http.get(`${harborUrl}/api/v2.0/robots`, () => { + callCount++ + return HttpResponse.json([], { status: HttpStatus.OK }) + }), + ) + + const robots: HarborRobot[] = [] + for await (const robot of service.getProjectRobots(42)) robots.push(robot) + + expect(callCount).toBe(1) + expect(robots).toEqual([]) + }) + }) + + describe('getRetentionId', () => { + it('returns null when project lookup fails', async () => { + server.use( + http.get(`${harborUrl}/api/v2.0/projects/:projectName`, () => + HttpResponse.json({}, { status: HttpStatus.NOT_FOUND })), + ) + await expect(service.getRetentionId('ghost')).resolves.toBeNull() + }) + + it('returns null when retention_id is absent or NaN', async () => { + server.use( + http.get(`${harborUrl}/api/v2.0/projects/:projectName`, () => + HttpResponse.json({ project_id: 1, metadata: {} }, { status: HttpStatus.OK })), + ) + await expect(service.getRetentionId('myproj')).resolves.toBeNull() + }) + + it('returns the numeric retention id from metadata', async () => { + server.use( + http.get(`${harborUrl}/api/v2.0/projects/:projectName`, () => + HttpResponse.json({ project_id: 1, metadata: { retention_id: '77' } }, { status: HttpStatus.OK })), + ) + await expect(service.getRetentionId('myproj')).resolves.toBe(77) + }) + }) })