Skip to content

Commit f8cbd45

Browse files
committed
pr comments and added cypress tests
1 parent 059dfb8 commit f8cbd45

File tree

13 files changed

+248
-17
lines changed

13 files changed

+248
-17
lines changed

packages/cypress/cypress/pages/modelsAsAService.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,74 @@ class CopyApiKeyModal extends Modal {
540540
}
541541
}
542542

543+
class SubscriptionsPage {
544+
visit(): void {
545+
cy.visitWithLogin('/maas/subscriptions');
546+
this.wait();
547+
}
548+
549+
private wait(): void {
550+
cy.findByTestId('app-page-title').should('exist');
551+
cy.testA11y();
552+
}
553+
554+
findTable(): Cypress.Chainable<JQuery<HTMLElement>> {
555+
return cy.findByTestId('subscriptions-table');
556+
}
557+
558+
findRows(): Cypress.Chainable<JQuery<HTMLElement>> {
559+
return this.findTable().find('tbody tr');
560+
}
561+
562+
findActionsToggle(): Cypress.Chainable<JQuery<HTMLElement>> {
563+
return cy.findByTestId('subscriptions-actions');
564+
}
565+
566+
getRow(name: string): SubscriptionTableRow {
567+
return new SubscriptionTableRow(() =>
568+
this.findTable().find('tbody tr').contains('td', name).parents('tr'),
569+
);
570+
}
571+
572+
findTitle(): Cypress.Chainable<JQuery<HTMLElement>> {
573+
return cy.findByTestId('app-page-title');
574+
}
575+
576+
findDescription(): Cypress.Chainable<JQuery<HTMLElement>> {
577+
return cy.findByTestId('app-page-description');
578+
}
579+
580+
findFilterInput(): Cypress.Chainable<JQuery<HTMLElement>> {
581+
return cy.findByTestId('subscriptions-filter-input');
582+
}
583+
584+
findFilterResetButton(): Cypress.Chainable<JQuery<HTMLElement>> {
585+
return cy.findByRole('button', { name: 'Clear all filters' });
586+
}
587+
588+
findCreateSubscriptionButton(): Cypress.Chainable<JQuery<HTMLElement>> {
589+
return cy.findByTestId('create-subscription-button');
590+
}
591+
592+
findEmptyState(): Cypress.Chainable<JQuery<HTMLElement>> {
593+
return cy.findByTestId('empty-state-title');
594+
}
595+
}
596+
597+
class SubscriptionTableRow extends TableRow {
598+
findName(): Cypress.Chainable<JQuery<HTMLElement>> {
599+
return this.find().find('[data-label="Name"]');
600+
}
601+
602+
findGroups(): Cypress.Chainable<JQuery<HTMLElement>> {
603+
return this.find().find('[data-label="Groups"]');
604+
}
605+
606+
findModels(): Cypress.Chainable<JQuery<HTMLElement>> {
607+
return this.find().find('[data-label="Models"]');
608+
}
609+
}
610+
543611
export const tiersPage = new TiersPage();
544612
export const createTierPage = new CreateTierPage();
545613
export const deleteTierModal = new DeleteTierModal();
@@ -550,3 +618,4 @@ export const bulkRevokeAPIKeyModal = new BulkRevokeAPIKeyModal();
550618
export const revokeAPIKeyModal = new RevokeAPIKeyModal();
551619
export const createApiKeyModal = new CreateApiKeyModal();
552620
export const copyApiKeyModal = new CopyApiKeyModal();
621+
export const subscriptionsPage = new SubscriptionsPage();

packages/cypress/cypress/support/commands/odh.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import type {
9797
BulkRevokeResponse,
9898
CreateAPIKeyResponse,
9999
} from '@odh-dashboard/maas/types/api-key';
100+
import type { MaaSSubscription } from '@odh-dashboard/maas/types/subscriptions';
100101

101102
type SuccessErrorResponse = {
102103
success: boolean;
@@ -1142,6 +1143,10 @@ declare global {
11421143
((
11431144
type: 'GET /maas/api/v1/namespaces',
11441145
response: OdhResponse<{ data: { metadata: { name: string } }[] }>,
1146+
) => Cypress.Chainable<null>) &
1147+
((
1148+
type: 'GET /maas/api/v1/all-subscriptions',
1149+
response: OdhResponse<{ data: MaaSSubscription[] }>,
11451150
) => Cypress.Chainable<null>);
11461151
}
11471152
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { mockDashboardConfig, mockDscStatus } from '@odh-dashboard/internal/__mocks__';
2+
import { DataScienceStackComponent } from '@odh-dashboard/internal/concepts/areas/types';
3+
import { asProductAdminUser } from '../../../utils/mockUsers';
4+
import { subscriptionsPage } from '../../../pages/modelsAsAService';
5+
import { mockSubscriptions } from '../../../utils/maasUtils';
6+
7+
describe('Subscriptions Page', () => {
8+
beforeEach(() => {
9+
asProductAdminUser();
10+
cy.interceptOdh(
11+
'GET /api/config',
12+
mockDashboardConfig({
13+
modelAsService: true,
14+
}),
15+
);
16+
cy.interceptOdh('GET /maas/api/v1/user', {
17+
data: { userId: 'test-user', clusterAdmin: false },
18+
});
19+
cy.interceptOdh('GET /maas/api/v1/namespaces', { data: [] });
20+
cy.interceptOdh(
21+
'GET /api/dsc/status',
22+
mockDscStatus({
23+
components: {
24+
[DataScienceStackComponent.LLAMA_STACK_OPERATOR]: { managementState: 'Managed' },
25+
},
26+
}),
27+
);
28+
cy.interceptOdh('GET /maas/api/v1/all-subscriptions', {
29+
data: mockSubscriptions(),
30+
});
31+
subscriptionsPage.visit();
32+
});
33+
34+
it('should show the empty state when there are no subscriptions', () => {
35+
cy.interceptOdh('GET /maas/api/v1/all-subscriptions', {
36+
data: [],
37+
});
38+
subscriptionsPage.visit();
39+
subscriptionsPage.findEmptyState().should('exist');
40+
subscriptionsPage.findCreateSubscriptionButton().should('exist');
41+
});
42+
43+
it('should display the subscriptions table with correct page content', () => {
44+
subscriptionsPage.findTitle().should('contain.text', 'Subscriptions');
45+
subscriptionsPage
46+
.findDescription()
47+
.should(
48+
'contain.text',
49+
'Subscriptions control access and entitlements to AI model endpoints that are available as a service.',
50+
);
51+
52+
subscriptionsPage.findTable().should('exist');
53+
subscriptionsPage.findRows().should('have.length', 2);
54+
subscriptionsPage.findCreateSubscriptionButton().should('exist');
55+
56+
const premiumRow = subscriptionsPage.getRow('premium-team-sub');
57+
premiumRow.findName().should('contain.text', 'premium-team-sub');
58+
premiumRow.findGroups().should('contain.text', '1 Groups');
59+
premiumRow.findModels().should('contain.text', '2 Models');
60+
61+
const basicRow = subscriptionsPage.getRow('basic-team-sub');
62+
basicRow.findName().should('contain.text', 'basic-team-sub');
63+
basicRow.findGroups().should('contain.text', '1 Groups');
64+
basicRow.findModels().should('contain.text', '1 Models');
65+
66+
subscriptionsPage.findFilterInput().should('exist').type('premium');
67+
subscriptionsPage.findRows().should('have.length', 1);
68+
subscriptionsPage.findFilterResetButton().should('exist').click();
69+
subscriptionsPage.findRows().should('have.length', 2);
70+
});
71+
});

packages/cypress/cypress/utils/maasUtils.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
CreateAPIKeyResponse,
55
CreateAPIKeyRequest,
66
} from '@odh-dashboard/maas/types/api-key';
7+
import type { MaaSSubscription } from '@odh-dashboard/maas/types/subscriptions';
78

89
// Standardized tier templates - use these directly or as building blocks
910
export const MOCK_TIERS: Record<'free' | 'premium' | 'enterprise', Tier> = {
@@ -98,6 +99,51 @@ export const mockCreateAPIKeyRequest = (): CreateAPIKeyRequest => {
9899
};
99100
};
100101

102+
export const mockSubscriptions = (): MaaSSubscription[] => [
103+
{
104+
name: 'premium-team-sub',
105+
namespace: 'maas-system',
106+
phase: 'Active',
107+
priority: 10,
108+
owner: {
109+
groups: [{ name: 'premium-users' }],
110+
},
111+
modelRefs: [
112+
{
113+
name: 'granite-3-8b-instruct',
114+
namespace: 'maas-models',
115+
tokenRateLimits: [{ limit: 100000, window: '24h' }],
116+
},
117+
{
118+
name: 'flan-t5-small',
119+
namespace: 'maas-models',
120+
tokenRateLimits: [{ limit: 200000, window: '24h' }],
121+
},
122+
],
123+
tokenMetadata: {
124+
organizationId: 'org-123',
125+
costCenter: 'engineering',
126+
},
127+
creationTimestamp: '2025-03-01T10:00:00Z',
128+
},
129+
{
130+
name: 'basic-team-sub',
131+
namespace: 'maas-system',
132+
phase: 'Active',
133+
owner: {
134+
groups: [{ name: 'system:authenticated' }],
135+
},
136+
modelRefs: [
137+
{
138+
name: 'flan-t5-small',
139+
namespace: 'maas-models',
140+
tokenRateLimits: [{ limit: 10000, window: '24h' }],
141+
},
142+
],
143+
creationTimestamp: '2025-02-15T08:00:00Z',
144+
},
145+
];
146+
101147
export const mockTier = ({
102148
name = 'free',
103149
displayName,

packages/maas/frontend/src/app/api/subscriptions.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,34 @@
11
import { handleRestFailures, isModArchResponse, APIOptions, restGET } from 'mod-arch-core';
22
import { BFF_API_VERSION, URL_PREFIX } from '../utilities/const';
3-
import { MaaSSubscription } from '../types/subscriptions';
3+
import { MaaSSubscription, ModelSubscriptionRef, TokenRateLimit } from '../types/subscriptions';
4+
5+
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object';
6+
7+
const isMaaSSubscriptionRef = (v: unknown): v is ModelSubscriptionRef =>
8+
isRecord(v) &&
9+
typeof v.name === 'string' &&
10+
typeof v.namespace === 'string' &&
11+
Array.isArray(v.tokenRateLimits) &&
12+
v.tokenRateLimits.every(isTokenRateLimit) &&
13+
(v.tokenRateLimitRef === undefined || typeof v.tokenRateLimitRef === 'string') &&
14+
(v.billingRate === undefined || typeof v.billingRate === 'object');
15+
16+
const isMaaSSubscription = (v: unknown): v is MaaSSubscription =>
17+
isRecord(v) &&
18+
typeof v.name === 'string' &&
19+
typeof v.namespace === 'string' &&
20+
typeof v.phase === 'string' &&
21+
(v.priority === undefined || typeof v.priority === 'number') &&
22+
typeof v.owner === 'object' &&
23+
Array.isArray(v.modelRefs) &&
24+
v.modelRefs.every(isMaaSSubscriptionRef) &&
25+
(v.tokenMetadata === undefined || typeof v.tokenMetadata === 'object') &&
26+
typeof v.creationTimestamp === 'string';
27+
const isTokenRateLimit = (v: unknown): v is TokenRateLimit =>
28+
isRecord(v) && typeof v.limit === 'number' && typeof v.window === 'string';
29+
30+
const isMaaSSubscriptionArray = (v: unknown): v is MaaSSubscription[] =>
31+
Array.isArray(v) && v.every(isMaaSSubscription);
432

533
/** GET /api/v1/all-subscriptions - List all subscriptions */
634
export const listSubscriptions =
@@ -9,7 +37,7 @@ export const listSubscriptions =
937
handleRestFailures(
1038
restGET(hostPath, `${URL_PREFIX}/api/${BFF_API_VERSION}/all-subscriptions`, {}, opts),
1139
).then((response) => {
12-
if (isModArchResponse<MaaSSubscription[]>(response)) {
40+
if (isModArchResponse<unknown>(response) && isMaaSSubscriptionArray(response.data)) {
1341
return response.data;
1442
}
1543
throw new Error('Invalid response format');

packages/maas/frontend/src/app/hooks/useListSubscriptions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { MaaSSubscription } from '../types/subscriptions';
1111

1212
export const useListSubscriptions = (): FetchState<MaaSSubscription[]> => {
1313
const callback = React.useCallback<FetchStateCallbackPromise<MaaSSubscription[]>>(
14-
(opts: APIOptions) => listSubscriptions()(opts).then((response) => response),
14+
(opts: APIOptions) => listSubscriptions()(opts),
1515
[],
1616
);
1717

packages/maas/frontend/src/app/pages/subscriptions/AllSubscriptionsPage.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ const AllSubscriptionsPage: React.FC = () => {
3434
: subscriptions;
3535
}, [subscriptions, filterData]);
3636

37-
const [, setIsDeleteModalOpen] = React.useState(false);
3837
const [deleteSubscription, setDeleteSubscription] = React.useState<MaaSSubscription | undefined>(
3938
undefined,
4039
);
@@ -60,11 +59,10 @@ const AllSubscriptionsPage: React.FC = () => {
6059
/>
6160
</PageSection>
6261
)}
63-
{deleteSubscription && deleteSubscription.name && (
62+
{deleteSubscription && (
6463
<DeleteSubscriptionModal
6564
subscription={deleteSubscription}
6665
onClose={() => {
67-
setIsDeleteModalOpen(false);
6866
setDeleteSubscription(undefined);
6967
}}
7068
/>

packages/maas/frontend/src/app/pages/subscriptions/EmptySubscriptionsPage.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ const EmptySubscriptionsPage: React.FC = () => {
1414
description="To get started, create a subscription."
1515
imageAlt="create a subscription"
1616
createButton={
17-
<Button variant="primary" onClick={() => navigate(`${URL_PREFIX}/subscriptions/create`)}>
17+
<Button
18+
variant="primary"
19+
onClick={() => navigate(`${URL_PREFIX}/subscriptions/create`)}
20+
data-testid="create-subscription-button"
21+
>
1822
Create Subscription
1923
</Button>
2024
}

packages/maas/frontend/src/app/pages/subscriptions/allSubscriptions/SubscriptionTableRow.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react';
2-
import { ActionsColumn, capitalize, Td, Tr } from '@patternfly/react-table';
2+
import { ActionsColumn, Td, Tr } from '@patternfly/react-table';
33
import TableRowTitleDescription from '@odh-dashboard/internal/components/table/TableRowTitleDescription';
44
import { Label } from '@patternfly/react-core';
55
import { Link, useNavigate } from 'react-router-dom';
@@ -9,11 +9,13 @@ import { subscriptionsColumns } from './columns';
99

1010
type SubscriptionTableRowProps = {
1111
subscription: MaaSSubscription;
12+
key: string;
1213
setDeleteSubscription: (subscription: MaaSSubscription) => void;
1314
};
1415

1516
const SubscriptionTableRow: React.FC<SubscriptionTableRowProps> = ({
1617
subscription,
18+
key,
1719
setDeleteSubscription,
1820
}) => {
1921
const navigate = useNavigate();
@@ -29,7 +31,7 @@ const SubscriptionTableRow: React.FC<SubscriptionTableRowProps> = ({
2931
};
3032

3133
return (
32-
<Tr>
34+
<Tr key={key}>
3335
<Td dataLabel={subscriptionsColumns[0].label}>
3436
<TableRowTitleDescription
3537
title={
@@ -41,14 +43,10 @@ const SubscriptionTableRow: React.FC<SubscriptionTableRowProps> = ({
4143
/>
4244
</Td>
4345
<Td dataLabel={subscriptionsColumns[1].label}>
44-
<Label color="grey">
45-
{capitalize(`${subscription.owner.groups.length.toString()} Groups`)}
46-
</Label>
46+
<Label color="grey">{`${subscription.owner.groups.length.toString()} Groups`}</Label>
4747
</Td>
4848
<Td dataLabel={subscriptionsColumns[2].label}>
49-
<Label color="grey">
50-
{capitalize(`${subscription.modelRefs.length.toString()} Models`)}
51-
</Label>
49+
<Label color="grey">{`${subscription.modelRefs.length.toString()} Models`}</Label>
5250
</Td>
5351
<Td isActionCell>
5452
<ActionsColumn

packages/maas/frontend/src/app/pages/subscriptions/allSubscriptions/SubscriptionsTable.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ export const SubscriptionsTable: React.FC<SubscriptionTableProps> = ({
1919
setDeleteSubscription,
2020
}): React.ReactNode => (
2121
<Table
22+
data-testid="subscriptions-table"
2223
data={subscriptions}
2324
columns={subscriptionsColumns}
2425
enablePagination
2526
rowRenderer={(subscription: MaaSSubscription) => (
2627
<SubscriptionTableRow
28+
key={subscription.name}
2729
subscription={subscription}
2830
setDeleteSubscription={setDeleteSubscription}
2931
/>

0 commit comments

Comments
 (0)