1- import { CoreV1Api , KubernetesObjectApi , PatchStrategy , V1Service } from '@kubernetes/client-node'
1+ import { CoreV1Api , CustomObjectsApi , KubernetesObjectApi , PatchStrategy , V1Service } from '@kubernetes/client-node'
22import {
33 getApiStatusFromConfigMap ,
44 getCloudttyActiveTime ,
55 getLogTime ,
6+ getServiceStatus ,
67 mergeCanaryServices ,
8+ parseHTTPRouteStatus ,
79 setApiStatusInConfigMap ,
810 toK8sService ,
911} from './k8s-operations'
12+ import { AplServiceResponse } from './otomi-models'
1013
1114// Mock the KubeConfig
1215jest . mock ( '@kubernetes/client-node' , ( ) => {
@@ -22,6 +25,9 @@ jest.mock('@kubernetes/client-node', () => {
2225 if ( apiClientType === actual . KubernetesObjectApi ) {
2326 return new actual . KubernetesObjectApi ( )
2427 }
28+ if ( apiClientType === actual . CustomObjectsApi ) {
29+ return new actual . CustomObjectsApi ( )
30+ }
2531 return { }
2632 } ) ,
2733 } ) ) ,
@@ -231,3 +237,179 @@ describe('setApiStatusInConfigMap', () => {
231237 )
232238 } )
233239} )
240+
241+ // ---------------------------------------------------------------------------
242+ // Helpers
243+ // ---------------------------------------------------------------------------
244+
245+ const makeServiceResponse = ( name : string , teamName : string , overrides : Record < string , any > = { } ) : AplServiceResponse =>
246+ ( {
247+ kind : 'AplTeamService' ,
248+ metadata : { name, labels : { 'apl.io/teamId' : teamName } } ,
249+ spec : { } ,
250+ ...overrides ,
251+ } ) as unknown as AplServiceResponse
252+
253+ const makeHTTPRoute = (
254+ conditions : { type : string ; status : string } [ ] ,
255+ options : { parentGatewayName ?: string ; visibility ?: string } = { } ,
256+ ) => ( {
257+ metadata : {
258+ labels : {
259+ 'networking.knative.dev/visibility' : options . visibility ?? '' ,
260+ } ,
261+ } ,
262+ spec : {
263+ parentRefs : [ { name : options . parentGatewayName ?? 'platform' } ] ,
264+ } ,
265+ status : {
266+ parents : [
267+ {
268+ conditions,
269+ controllerName : 'istio.io/gateway-controller' ,
270+ parentRef : {
271+ group : 'gateway.networking.k8s.io' ,
272+ kind : 'Gateway' ,
273+ name : options . parentGatewayName ?? 'platform' ,
274+ } ,
275+ } ,
276+ ] ,
277+ } ,
278+ } )
279+
280+ // ---------------------------------------------------------------------------
281+ // parseHTTPRouteStatus
282+ // ---------------------------------------------------------------------------
283+
284+ describe ( 'parseHTTPRouteStatus' , ( ) => {
285+ test ( 'returns true when Accepted and ResolvedRefs are both True' , ( ) => {
286+ const route = makeHTTPRoute ( [
287+ { type : 'Accepted' , status : 'True' } ,
288+ { type : 'ResolvedRefs' , status : 'True' } ,
289+ ] )
290+ expect ( parseHTTPRouteStatus ( route ) ) . toBe ( true )
291+ } )
292+
293+ test ( 'returns false when Accepted is False' , ( ) => {
294+ const route = makeHTTPRoute ( [
295+ { type : 'Accepted' , status : 'False' } ,
296+ { type : 'ResolvedRefs' , status : 'True' } ,
297+ ] )
298+ expect ( parseHTTPRouteStatus ( route ) ) . toBe ( false )
299+ } )
300+
301+ test ( 'returns false when ResolvedRefs is False' , ( ) => {
302+ const route = makeHTTPRoute ( [
303+ { type : 'Accepted' , status : 'True' } ,
304+ { type : 'ResolvedRefs' , status : 'False' } ,
305+ ] )
306+ expect ( parseHTTPRouteStatus ( route ) ) . toBe ( false )
307+ } )
308+
309+ test ( 'returns false when conditions are empty' , ( ) => {
310+ expect ( parseHTTPRouteStatus ( makeHTTPRoute ( [ ] ) ) ) . toBe ( false )
311+ } )
312+
313+ test ( 'returns false when parents array is missing' , ( ) => {
314+ expect ( parseHTTPRouteStatus ( { status : { } } ) ) . toBe ( false )
315+ } )
316+ } )
317+
318+ // ---------------------------------------------------------------------------
319+ // getServiceStatus
320+ // ---------------------------------------------------------------------------
321+
322+ describe ( 'getServiceStatus' , ( ) => {
323+ afterEach ( ( ) => {
324+ jest . clearAllMocks ( )
325+ } )
326+
327+ const mockList = ( items : any [ ] ) =>
328+ jest . spyOn ( CustomObjectsApi . prototype , 'listNamespacedCustomObject' ) . mockResolvedValue ( { items } as any )
329+
330+ test ( 'returns NotFound when no HTTPRoutes exist' , async ( ) => {
331+ mockList ( [ ] )
332+ const result = await getServiceStatus ( makeServiceResponse ( 'web' , 'alpha' ) )
333+ expect ( result ) . toBe ( 'NotFound' )
334+ } )
335+
336+ test ( 'returns NotFound when only local HTTPRoutes exist' , async ( ) => {
337+ mockList ( [
338+ makeHTTPRoute (
339+ [
340+ { type : 'Accepted' , status : 'True' } ,
341+ { type : 'ResolvedRefs' , status : 'True' } ,
342+ ] ,
343+ { parentGatewayName : 'knative-local-gateway' , visibility : 'cluster-local' } ,
344+ ) ,
345+ ] )
346+ const result = await getServiceStatus ( makeServiceResponse ( 'web' , 'alpha' ) )
347+ expect ( result ) . toBe ( 'NotFound' )
348+ } )
349+
350+ test ( 'returns Unknown when multiple HTTPRoutes are found' , async ( ) => {
351+ mockList ( [ makeHTTPRoute ( [ ] ) , makeHTTPRoute ( [ ] ) ] )
352+ const result = await getServiceStatus ( makeServiceResponse ( 'web' , 'alpha' ) )
353+ expect ( result ) . toBe ( 'Unknown' )
354+ } )
355+
356+ test ( 'returns Succeeded when single HTTPRoute is accepted' , async ( ) => {
357+ mockList ( [
358+ makeHTTPRoute ( [
359+ { type : 'Accepted' , status : 'True' } ,
360+ { type : 'ResolvedRefs' , status : 'True' } ,
361+ ] ) ,
362+ ] )
363+ const result = await getServiceStatus ( makeServiceResponse ( 'web' , 'alpha' ) )
364+ expect ( result ) . toBe ( 'Succeeded' )
365+ } )
366+
367+ test ( 'returns Succeeded when local and public routes exist and public is accepted' , async ( ) => {
368+ mockList ( [
369+ makeHTTPRoute (
370+ [
371+ { type : 'Accepted' , status : 'True' } ,
372+ { type : 'ResolvedRefs' , status : 'True' } ,
373+ ] ,
374+ { parentGatewayName : 'knative-local-gateway' , visibility : 'cluster-local' } ,
375+ ) ,
376+ makeHTTPRoute ( [
377+ { type : 'Accepted' , status : 'True' } ,
378+ { type : 'ResolvedRefs' , status : 'True' } ,
379+ ] ) ,
380+ ] )
381+ const result = await getServiceStatus ( makeServiceResponse ( 'web' , 'alpha' ) )
382+ expect ( result ) . toBe ( 'Succeeded' )
383+ } )
384+
385+ test ( 'returns Unknown when single HTTPRoute is not accepted' , async ( ) => {
386+ mockList ( [
387+ makeHTTPRoute ( [
388+ { type : 'Accepted' , status : 'False' } ,
389+ { type : 'ResolvedRefs' , status : 'True' } ,
390+ ] ) ,
391+ ] )
392+ const result = await getServiceStatus ( makeServiceResponse ( 'web' , 'alpha' ) )
393+ expect ( result ) . toBe ( 'Unknown' )
394+ } )
395+
396+ test ( 'queries with the correct label selector and namespace' , async ( ) => {
397+ const spy = mockList ( [ ] )
398+ await getServiceStatus ( makeServiceResponse ( 'my-app' , 'dev' ) )
399+ expect ( spy ) . toHaveBeenCalledWith (
400+ expect . objectContaining ( {
401+ group : 'gateway.networking.k8s.io' ,
402+ version : 'v1' ,
403+ namespace : 'team-dev' ,
404+ plural : 'httproutes' ,
405+ labelSelector : 'otomi.io/app=my-app' ,
406+ } ) ,
407+ )
408+ } )
409+
410+ test ( 'returns NotFound when the k8s API call throws' , async ( ) => {
411+ jest . spyOn ( CustomObjectsApi . prototype , 'listNamespacedCustomObject' ) . mockRejectedValue ( new Error ( 'network error' ) )
412+ const result = await getServiceStatus ( makeServiceResponse ( 'web' , 'alpha' ) )
413+ expect ( result ) . toBe ( 'NotFound' )
414+ } )
415+ } )
0 commit comments