diff --git a/hermes-console-vue/json-server/db.json b/hermes-console-vue/json-server/db.json index 670f0f4813..88ef25dcf5 100644 --- a/hermes-console-vue/json-server/db.json +++ b/hermes-console-vue/json-server/db.json @@ -99,8 +99,6 @@ "pl.allegro.public.offer.OfferEventV3", "pl.allegro.public.order.OrderEventV1", "pl.allegro.public.order.OrderEventV2", - "pl.allegro.public.user.UserCreatedEvent", - "pl.allegro.public.user.UserChangedEvent", "pl.allegro.public.group.DummyEvent", "pl.allegro.public.admin.AdminOfferActionEvent", "pl.allegro.public.admin.AdminOrderActionEvent" @@ -119,11 +117,11 @@ ], "readinessDatacenters": [ { - "datacenter": "DC1", + "datacenter": "DC1", "isReady": true }, { - "datacenter": "DC2", + "datacenter": "DC2", "isReady": false } ], @@ -136,8 +134,7 @@ "consumersNumber": 4 } }, - "subscriptionConstraints": - { + "subscriptionConstraints": { "pl.group.Topic$subscription": { "consumersNumber": 6 }, @@ -327,7 +324,7 @@ "requestTimeout": 1000, "socketTimeout": 0, "sendingDelay": 0, - "backoffMultiplier": 1.0, + "backoffMultiplier": 1, "backoffMaxIntervalInSec": 600, "retryClientErrors": true, "backoffMaxIntervalMillis": 600000 @@ -336,7 +333,7 @@ "trackingMode": "trackingOff", "owner": { "source": "Service Catalog", - "id": "42" + "id": "41" }, "monitoringDetails": { "severity": "NON_IMPORTANT", @@ -401,7 +398,7 @@ "trackingMode": "trackingOff", "owner": { "source": "Service Catalog", - "id": "42" + "id": "41" }, "monitoringDetails": { "severity": "NON_IMPORTANT", @@ -564,10 +561,12 @@ } }, "owner": { - "sources": [{ - "name": "Crowd", - "placeholder": "Crowd group (or groups separated by ',')" - }] + "sources": [ + { + "name": "Crowd", + "placeholder": "Crowd group (or groups separated by ',')" + } + ] }, "topic": { "messagePreviewEnabled": true, @@ -592,19 +591,24 @@ "removeSchema": false, "schemaIdAwareSerializationEnabled": false, "avroContentTypeMetadataRequired": true, - "contentTypes": [{ - "value": "AVRO", - "label": "AVRO" - }, { - "value": "JSON", - "label": "JSON" - }], + "contentTypes": [ + { + "value": "AVRO", + "label": "AVRO" + }, + { + "value": "JSON", + "label": "JSON" + } + ], "readOnlyModeEnabled": false, "allowedTopicLabels": [], - "retentionUnits": [{ - "value": "DAYS", - "label": "DAYS" - }], + "retentionUnits": [ + { + "value": "DAYS", + "label": "DAYS" + } + ], "offlineRetransmissionEnabled": false, "offlineRetransmissionDescription": "Offline retransmission" }, @@ -626,13 +630,16 @@ }, "deliveryType": "SERIAL" }, - "deliveryTypes": [{ - "value": "SERIAL", - "label": "SERIAL" - }, { - "value": "BATCH", - "label": "BATCH" - }] + "deliveryTypes": [ + { + "value": "SERIAL", + "label": "SERIAL" + }, + { + "value": "BATCH", + "label": "BATCH" + } + ] }, "consistency": { "maxGroupBatchSize": 10 @@ -654,4 +661,4 @@ "avroSubscriptionCount": 100 } } -} +} \ No newline at end of file diff --git a/hermes-console-vue/json-server/server.ts b/hermes-console-vue/json-server/server.ts index b24f67b22c..23d7cbd20b 100644 --- a/hermes-console-vue/json-server/server.ts +++ b/hermes-console-vue/json-server/server.ts @@ -23,6 +23,30 @@ server.post('/topics/*/subscriptions/*/moveOffsetsToTheEnd', (req, res) => { res.sendStatus(200); }); +server.delete('/groups/:group', (req, res) => { + res.sendStatus(200); +}); + +server.delete('/topics/:topic', (req, res) => { + res.sendStatus(200); +}); + +server.delete('/subscriptions/:subscription', (req, res) => { + res.sendStatus(200); +}); + +server.put('/topics/*/subscriptions/*/state', (req, res) => { + res.sendStatus(200); +}); + +server.delete('/consistency/inconsistencies/topics', (req, res) => { + res.sendStatus(200); +}); + +server.post('/readiness/datacenters/:dc', (req, res) => { + res.sendStatus(200); +}); + const router = jsonServer.router('json-server/db.json'); server.use(router); diff --git a/hermes-console-vue/src/api/hermes-client/index.ts b/hermes-console-vue/src/api/hermes-client/index.ts index ede389f11e..f946aec50b 100644 --- a/hermes-console-vue/src/api/hermes-client/index.ts +++ b/hermes-console-vue/src/api/hermes-client/index.ts @@ -1,4 +1,5 @@ -import axios from 'axios'; +import { State } from '@/api/subscription'; +import axios from '@/utils/axios/axios-instance'; import qs from 'query-string'; import type { AccessTokenResponse } from '@/api/access-token-response'; import type { AppConfiguration } from '@/api/app-configuration'; @@ -15,10 +16,13 @@ import type { } from '@/api/topic'; import type { OfflineClientsSource } from '@/api/offline-clients-source'; import type { Owner } from '@/api/owner'; -import type { ResponsePromise } from '@/utils/axios-utils'; +import type { ResponsePromise } from '@/utils/axios/axios-utils'; import type { Role } from '@/api/role'; +import type { SentMessageTrace } from '@/api/subscription-undelivered'; import type { Stats } from '@/api/stats'; import type { Subscription } from '@/api/subscription'; +import type { SubscriptionHealth } from '@/api/subscription-health'; +import type { SubscriptionMetrics } from '@/api/subscription-metrics'; export function fetchTopic( topicName: string, @@ -26,7 +30,7 @@ export function fetchTopic( return axios.get(`/topics/${topicName}`); } -export function fetchTopicOwner(ownerId: string): ResponsePromise { +export function fetchOwner(ownerId: string): ResponsePromise { return axios.get(`/owners/sources/Service Catalog/${ownerId}`); } @@ -57,6 +61,71 @@ export function fetchTopicSubscriptionDetails( ); } +export function fetchSubscription( + topicName: string, + subscriptionName: string, +): ResponsePromise { + return axios.get( + `/topics/${topicName}/subscriptions/${subscriptionName}`, + ); +} + +export function suspendSubscription( + topicName: string, + subscriptionName: string, +): ResponsePromise { + return axios.put( + `/topics/${topicName}/subscriptions/${subscriptionName}/state`, + State.SUSPENDED, + ); +} + +export function activateSubscription( + topicName: string, + subscriptionName: string, +): ResponsePromise { + return axios.put( + `/topics/${topicName}/subscriptions/${subscriptionName}/state`, + State.ACTIVE, + ); +} + +export function fetchSubscriptionMetrics( + topicName: string, + subscriptionName: string, +): ResponsePromise { + return axios.get( + `/topics/${topicName}/subscriptions/${subscriptionName}/metrics`, + ); +} + +export function fetchSubscriptionHealth( + topicName: string, + subscriptionName: string, +): ResponsePromise { + return axios.get( + `/topics/${topicName}/subscriptions/${subscriptionName}/health`, + ); +} + +export function fetchSubscriptionUndeliveredMessages( + topicName: string, + subscriptionName: string, +): ResponsePromise { + return axios.get( + `/topics/${topicName}/subscriptions/${subscriptionName}/undelivered`, + ); +} + +export function fetchSubscriptionLastUndeliveredMessage( + topicName: string, + subscriptionName: string, +): ResponsePromise { + return axios.get( + `/topics/${topicName}/subscriptions/${subscriptionName}/undelivered/last`, + ); +} + export function fetchAppConfiguration(): ResponsePromise { return axios.get('/console', { headers: { accept: 'application/json' }, @@ -168,3 +237,44 @@ export function moveSubscriptionOffsets( `/topics/${topicName}/subscriptions/${subscription}/moveOffsetsToTheEnd`, ); } + +export function removeTopic(topic: String): ResponsePromise { + return axios.delete(`/topics/${topic}`); +} + +export function removeSubscription( + topic: String, + subscription: String, +): ResponsePromise { + return axios.delete(`/topics/${topic}/subscriptions/${subscription}`); +} + +export function removeGroup(group: String): ResponsePromise { + return axios.delete(`/groups/${group}`); +} + +export function removeInconsistentTopic(topic: string): ResponsePromise { + return axios.delete('/consistency/inconsistencies/topics', { + params: { + topicName: topic, + }, + paramsSerializer: { + indexes: null, + }, + }); +} + +export function switchReadiness( + datacenter: string, + desiredState: boolean, +): ResponsePromise { + return axios.post( + `/readiness/datacenters/${datacenter}`, + qs.stringify({ + isReady: desiredState, + }), + { + 'Content-Type': 'application/json', + } as AxiosRequestConfig, + ); +} diff --git a/hermes-console-vue/src/components/app-notification/AppNotification.vue b/hermes-console-vue/src/components/app-notification/AppNotification.vue index 944963e4fa..6d0502c05a 100644 --- a/hermes-console-vue/src/components/app-notification/AppNotification.vue +++ b/hermes-console-vue/src/components/app-notification/AppNotification.vue @@ -3,7 +3,9 @@ const props = defineProps<{ notification: Notification; }>(); - defineEmits(['close']); + const emit = defineEmits<{ + close: []; + }>(); diff --git a/hermes-console-vue/src/components/confirmation-dialog/ConfirmationDialog.spec.ts b/hermes-console-vue/src/components/confirmation-dialog/ConfirmationDialog.spec.ts new file mode 100644 index 0000000000..5ac517f014 --- /dev/null +++ b/hermes-console-vue/src/components/confirmation-dialog/ConfirmationDialog.spec.ts @@ -0,0 +1,147 @@ +import { + appConfigStoreState, + createTestingPiniaWithState, +} from '@/dummy/store'; +import { createTestingPinia } from '@pinia/testing'; +import { dummyAppConfig } from '@/dummy/app-config'; +import { expect } from 'vitest'; +import { render } from '@/utils/test-utils'; +import ConfirmationDialog from '@/components/confirmation-dialog/ConfirmationDialog.vue'; +import userEvent from '@testing-library/user-event'; + +describe('ConfirmationDialog', () => { + const props = { + actionButtonEnabled: true, + title: 'Confirmation dialog', + text: 'Dummy text', + modelValue: true, + }; + + it('renders properly', () => { + // when + const { getByText, queryByText } = render(ConfirmationDialog, { + testPinia: createTestingPiniaWithState(), + props, + }); + + // then + expect(getByText('Confirmation dialog')).toBeVisible(); + expect( + queryByText('confirmationDialog.confirmText'), + ).not.toBeInTheDocument(); + expect(getByText('Dummy text')).toBeVisible(); + expect( + getByText('confirmationDialog.confirm').closest('button'), + ).toBeEnabled(); + expect( + getByText('confirmationDialog.cancel').closest('button'), + ).toBeEnabled(); + }); + + it('should disable action button', async () => { + //given + const props = { + actionButtonEnabled: false, + title: 'Confirmation dialog', + text: 'Dummy text', + modelValue: true, + }; + + // when + const { getByText } = render(ConfirmationDialog, { + testPinia: createTestingPiniaWithState(), + props, + }); + + // then + expect( + getByText('confirmationDialog.confirm').closest('button'), + ).toBeDisabled(); + }); + + it('should require confirmation text', () => { + // when + const { getByText, getAllByText } = render(ConfirmationDialog, { + testPinia: createTestingPinia({ + initialState: { + appConfig: { + ...appConfigStoreState, + appConfig: { + ...dummyAppConfig, + console: { + ...dummyAppConfig.console, + criticalEnvironment: true, + }, + }, + }, + }, + }), + props, + }); + + // then + expect( + getAllByText('confirmationDialog.confirmText')[0], + ).toBeInTheDocument(); + expect( + getByText('confirmationDialog.confirm').closest('button'), + ).toBeDisabled(); + expect( + getByText('confirmationDialog.cancel').closest('button'), + ).toBeEnabled(); + }); + + it('should enable button when confirmation text match', async () => { + // when + const { getByText, getAllByText, getAllByRole } = render( + ConfirmationDialog, + { + testPinia: createTestingPinia({ + initialState: { + appConfig: { + ...appConfigStoreState, + appConfig: { + ...dummyAppConfig, + console: { + ...dummyAppConfig.console, + criticalEnvironment: true, + }, + }, + }, + }, + }), + props, + }, + ); + + // then + expect( + getAllByText('confirmationDialog.confirmText')[0], + ).toBeInTheDocument(); + expect( + getByText('confirmationDialog.confirm').closest('button'), + ).toBeDisabled(); + expect( + getByText('confirmationDialog.cancel').closest('button'), + ).toBeEnabled(); + + //when + await userEvent.type(getAllByRole('textbox')[1], 'not matching text'); + + //then + expect(getAllByRole('textbox')[1]).toHaveValue('not matching text'); + expect( + getByText('confirmationDialog.confirm').closest('button'), + ).toBeDisabled(); + + //when + await userEvent.clear(getAllByRole('textbox')[1]); + await userEvent.type(getAllByRole('textbox')[1], 'prod'); + + //then + expect(getAllByRole('textbox')[1]).toHaveValue('prod'); + expect( + getByText('confirmationDialog.confirm').closest('button'), + ).toBeEnabled(); + }); +}); diff --git a/hermes-console-vue/src/components/confirmation-dialog/ConfirmationDialog.vue b/hermes-console-vue/src/components/confirmation-dialog/ConfirmationDialog.vue new file mode 100644 index 0000000000..349306c9e1 --- /dev/null +++ b/hermes-console-vue/src/components/confirmation-dialog/ConfirmationDialog.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/hermes-console-vue/src/components/console-header/ConsoleHeader.spec.ts b/hermes-console-vue/src/components/console-header/ConsoleHeader.spec.ts index 9a6611dffd..3c14ced6d9 100644 --- a/hermes-console-vue/src/components/console-header/ConsoleHeader.spec.ts +++ b/hermes-console-vue/src/components/console-header/ConsoleHeader.spec.ts @@ -5,8 +5,8 @@ import { } from '@/dummy/store'; import { createTestingPinia } from '@pinia/testing'; import { dummyAppConfig } from '@/dummy/app-config'; +import { dummyExpiredToken, dummyValidToken } from '@/dummy/jwt-tokens'; import { expect } from 'vitest'; -import { expiredToken, validToken } from '@/utils/jwt-utils'; import { render } from '@/utils/test-utils'; import ConsoleHeader from '@/components/console-header/ConsoleHeader.vue'; @@ -123,7 +123,7 @@ describe('ConsoleHeader', () => { }, auth: { ...authStoreState, - accessToken: validToken, + accessToken: dummyValidToken, }, }, }), @@ -153,7 +153,7 @@ describe('ConsoleHeader', () => { }, auth: { ...authStoreState, - accessToken: expiredToken, + accessToken: dummyExpiredToken, }, }, }), diff --git a/hermes-console-vue/src/composables/dialog/use-dialog/useDialog.ts b/hermes-console-vue/src/composables/dialog/use-dialog/useDialog.ts new file mode 100644 index 0000000000..2472a18f43 --- /dev/null +++ b/hermes-console-vue/src/composables/dialog/use-dialog/useDialog.ts @@ -0,0 +1,44 @@ +import { ref } from 'vue'; +import type { Ref } from 'vue'; + +export interface UseDialog { + isDialogOpened: Ref; + actionButtonEnabled: Ref; + openDialog: () => void; + closeDialog: () => void; + enableActionButton: () => void; + disableActionButton: () => void; +} + +export function useDialog( + isOpenedByDefault: boolean = false, + actionButtonEnabledByDefault: boolean = true, +): UseDialog { + const isDialogOpened = ref(isOpenedByDefault); + const actionButtonEnabled = ref(actionButtonEnabledByDefault); + + function openDialog() { + isDialogOpened.value = true; + } + + function closeDialog() { + isDialogOpened.value = false; + } + + function enableActionButton() { + actionButtonEnabled.value = true; + } + + function disableActionButton() { + actionButtonEnabled.value = false; + } + + return { + isDialogOpened, + actionButtonEnabled, + openDialog, + closeDialog, + enableActionButton, + disableActionButton, + }; +} diff --git a/hermes-console-vue/src/composables/groups/use-groups/useGroups.spec.ts b/hermes-console-vue/src/composables/groups/use-groups/useGroups.spec.ts index a1e11fe9a3..a00e2753f9 100644 --- a/hermes-console-vue/src/composables/groups/use-groups/useGroups.spec.ts +++ b/hermes-console-vue/src/composables/groups/use-groups/useGroups.spec.ts @@ -1,12 +1,20 @@ import { afterEach } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; import { dummyGroupNames } from '@/dummy/groups'; import { dummyTopicNames } from '@/dummy/topics'; +import { + expectNotificationDispatched, + notificationStoreSpy, +} from '@/utils/test-utils'; import { fetchGroupNamesErrorHandler, fetchGroupNamesHandler, fetchTopicNamesErrorHandler, fetchTopicNamesHandler, + removeGroupErrorHandler, + removeGroupHandler, } from '@/mocks/handlers'; +import { setActivePinia } from 'pinia'; import { setupServer } from 'msw/node'; import { useGroups } from '@/composables/groups/use-groups/useGroups'; import { waitFor } from '@testing-library/vue'; @@ -17,6 +25,14 @@ describe('useGroups', () => { fetchTopicNamesHandler({ topicNames: dummyTopicNames }), ); + const pinia = createTestingPinia({ + fakeApp: true, + }); + + beforeEach(() => { + setActivePinia(pinia); + }); + afterEach(() => { server.resetHandlers(); }); @@ -72,4 +88,46 @@ describe('useGroups', () => { expect(error.value.fetchTopicNames).not.toBeNull(); }); }); + + it('should show message that removing group was successful', async () => { + // given + server.use(removeGroupHandler({ group: dummyGroupNames[0] })); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { removeGroup } = useGroups(); + + // when + await removeGroup(dummyGroupNames[0]); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'success', + text: 'notifications.group.delete.success', + }); + }); + }); + + it('should show message that removing group was unsuccessful', async () => { + // given + server.use( + removeGroupErrorHandler({ group: dummyGroupNames[0], errorCode: 500 }), + ); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { removeGroup } = useGroups(); + + // when + await removeGroup(dummyGroupNames[0]); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'error', + title: 'notifications.group.delete.failure', + }); + }); + }); }); diff --git a/hermes-console-vue/src/composables/groups/use-groups/useGroups.ts b/hermes-console-vue/src/composables/groups/use-groups/useGroups.ts index d89b7f407b..9894e1323e 100644 --- a/hermes-console-vue/src/composables/groups/use-groups/useGroups.ts +++ b/hermes-console-vue/src/composables/groups/use-groups/useGroups.ts @@ -1,12 +1,18 @@ import { computed, ref } from 'vue'; -import { fetchGroupNames as getGroupNames } from '@/api/hermes-client'; +import { + removeGroup as deleteGroup, + fetchGroupNames as getGroupNames, +} from '@/api/hermes-client'; import { fetchTopicNames as getTopicNames } from '@/api/hermes-client'; +import { useGlobalI18n } from '@/i18n'; +import { useNotificationsStore } from '@/store/app-notifications/useAppNotifications'; import type { Ref } from 'vue'; export interface UseGroups { groups: Ref; loading: Ref; error: Ref; + removeGroup: (groupId: string) => Promise; } export interface UseGroupsErrors { @@ -20,6 +26,8 @@ export interface Group { } export function useGroups(): UseGroups { + const notificationStore = useNotificationsStore(); + const groupNames = ref(); const topicNames = ref(); const error = ref({ @@ -64,6 +72,28 @@ export function useGroups(): UseGroups { } }; + const removeGroup = async (groupId: string): Promise => { + try { + await deleteGroup(groupId); + notificationStore.dispatchNotification({ + text: useGlobalI18n().t('notifications.group.delete.success', { + groupId, + }), + type: 'success', + }); + return true; + } catch (e) { + notificationStore.dispatchNotification({ + title: useGlobalI18n().t('notifications.group.delete.failure', { + groupId, + }), + text: (e as Error).message, + type: 'error', + }); + return false; + } + }; + fetchGroupNames(); fetchTopicNames(); @@ -71,5 +101,6 @@ export function useGroups(): UseGroups { groups, loading, error, + removeGroup, }; } diff --git a/hermes-console-vue/src/composables/inconsistent-topics/use-inconsistent-topics/useInconsistentTopics.spec.ts b/hermes-console-vue/src/composables/inconsistent-topics/use-inconsistent-topics/useInconsistentTopics.spec.ts index 428891e357..0aabec74db 100644 --- a/hermes-console-vue/src/composables/inconsistent-topics/use-inconsistent-topics/useInconsistentTopics.spec.ts +++ b/hermes-console-vue/src/composables/inconsistent-topics/use-inconsistent-topics/useInconsistentTopics.spec.ts @@ -1,9 +1,17 @@ import { afterEach } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; import { dummyInconsistentTopics } from '@/dummy/inconsistentTopics'; +import { + expectNotificationDispatched, + notificationStoreSpy, +} from '@/utils/test-utils'; import { fetchInconsistentTopicsErrorHandler, fetchInconsistentTopicsHandler, + removeInconsistentTopicErrorHandler, + removeInconsistentTopicHandler, } from '@/mocks/handlers'; +import { setActivePinia } from 'pinia'; import { setupServer } from 'msw/node'; import { useInconsistentTopics } from '@/composables/inconsistent-topics/use-inconsistent-topics/useInconsistentTopics'; import { waitFor } from '@testing-library/vue'; @@ -13,6 +21,14 @@ describe('useInconsistentTopics', () => { fetchInconsistentTopicsHandler({ topics: dummyInconsistentTopics }), ); + const pinia = createTestingPinia({ + fakeApp: true, + }); + + beforeEach(() => { + setActivePinia(pinia); + }); + afterEach(() => { server.resetHandlers(); }); @@ -50,4 +66,44 @@ describe('useInconsistentTopics', () => { expect(error.value.fetchInconsistentTopics).not.toBeNull(); }); }); + + it('should show message that removing inconsistentTopic was successful', async () => { + // given + server.use(removeInconsistentTopicHandler()); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { removeInconsistentTopic } = useInconsistentTopics(); + + // when + await removeInconsistentTopic(dummyInconsistentTopics[0]); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'success', + text: 'notifications.inconsistentTopic.delete.success', + }); + }); + }); + + it('should show message that removing inconsistentTopic was unsuccessful', async () => { + // given + server.use(removeInconsistentTopicErrorHandler({ errorCode: 500 })); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { removeInconsistentTopic } = useInconsistentTopics(); + + // when + await removeInconsistentTopic(dummyInconsistentTopics[0]); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'error', + title: 'notifications.inconsistentTopic.delete.failure', + }); + }); + }); }); diff --git a/hermes-console-vue/src/composables/inconsistent-topics/use-inconsistent-topics/useInconsistentTopics.ts b/hermes-console-vue/src/composables/inconsistent-topics/use-inconsistent-topics/useInconsistentTopics.ts index bbf0a2bdd6..a2e104d40d 100644 --- a/hermes-console-vue/src/composables/inconsistent-topics/use-inconsistent-topics/useInconsistentTopics.ts +++ b/hermes-console-vue/src/composables/inconsistent-topics/use-inconsistent-topics/useInconsistentTopics.ts @@ -1,11 +1,17 @@ import { computed, ref } from 'vue'; -import { fetchInconsistentTopics as getInconsistentTopics } from '@/api/hermes-client'; +import { + removeInconsistentTopic as deleteInconsistentTopic, + fetchInconsistentTopics as getInconsistentTopics, +} from '@/api/hermes-client'; +import { useGlobalI18n } from '@/i18n'; +import { useNotificationsStore } from '@/store/app-notifications/useAppNotifications'; import type { Ref } from 'vue'; export interface UseInconsistentTopics { topics: Ref; loading: Ref; error: Ref; + removeInconsistentTopic: (topic: string) => Promise; } export interface UseInconsistentTopicsErrors { @@ -13,6 +19,8 @@ export interface UseInconsistentTopicsErrors { } export function useInconsistentTopics(): UseInconsistentTopics { + const notificationStore = useNotificationsStore(); + const topicNames = ref(); const error = ref({ fetchInconsistentTopics: null, @@ -34,11 +42,40 @@ export function useInconsistentTopics(): UseInconsistentTopics { } }; + const removeInconsistentTopic = async (topic: string): Promise => { + try { + await deleteInconsistentTopic(topic); + notificationStore.dispatchNotification({ + text: useGlobalI18n().t( + 'notifications.inconsistentTopic.delete.success', + { + topic, + }, + ), + type: 'success', + }); + return true; + } catch (e) { + notificationStore.dispatchNotification({ + title: useGlobalI18n().t( + 'notifications.inconsistentTopic.delete.failure', + { + topic, + }, + ), + text: (e as Error).message, + type: 'error', + }); + return false; + } + }; + fetchInconsistentTopics(); return { topics, loading, error, + removeInconsistentTopic, }; } diff --git a/hermes-console-vue/src/composables/readiness/use-readiness/useReadiness.spec.ts b/hermes-console-vue/src/composables/readiness/use-readiness/useReadiness.spec.ts index 2598fb696e..c57666be8b 100644 --- a/hermes-console-vue/src/composables/readiness/use-readiness/useReadiness.spec.ts +++ b/hermes-console-vue/src/composables/readiness/use-readiness/useReadiness.spec.ts @@ -1,9 +1,17 @@ import { afterEach } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; import { dummyDatacentersReadiness } from '@/dummy/readiness'; +import { + expectNotificationDispatched, + notificationStoreSpy, +} from '@/utils/test-utils'; import { fetchReadinessErrorHandler, fetchReadinessHandler, + switchReadinessErrorHandler, + switchReadinessHandler, } from '@/mocks/handlers'; +import { setActivePinia } from 'pinia'; import { setupServer } from 'msw/node'; import { useReadiness } from '@/composables/readiness/use-readiness/useReadiness'; import { waitFor } from '@testing-library/vue'; @@ -13,6 +21,18 @@ describe('useReadiness', () => { fetchReadinessHandler({ datacentersReadiness: dummyDatacentersReadiness }), ); + const pinia = createTestingPinia({ + fakeApp: true, + }); + + beforeEach(() => { + setActivePinia(pinia); + }); + + afterEach(() => { + server.resetHandlers(); + }); + afterEach(() => { server.resetHandlers(); }); @@ -48,4 +68,53 @@ describe('useReadiness', () => { expect(error.value.fetchReadiness).not.toBeNull(); }); }); + + it('should show message that switching readiness subscription was successful', async () => { + // given + server.use( + switchReadinessHandler({ + datacenter: dummyDatacentersReadiness[0].datacenter, + }), + ); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { switchReadinessState } = useReadiness(); + + // when + await switchReadinessState(dummyDatacentersReadiness[0].datacenter, true); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'success', + text: 'notifications.readiness.switch.success', + }); + }); + }); + + it('should show message that switching readiness subscription was unsuccessful', async () => { + // given + server.use( + switchReadinessErrorHandler({ + datacenter: dummyDatacentersReadiness[0].datacenter, + errorCode: 500, + }), + ); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { switchReadinessState } = useReadiness(); + + // when + await switchReadinessState(dummyDatacentersReadiness[0].datacenter, true); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'error', + title: 'notifications.readiness.switch.failure', + }); + }); + }); }); diff --git a/hermes-console-vue/src/composables/readiness/use-readiness/useReadiness.ts b/hermes-console-vue/src/composables/readiness/use-readiness/useReadiness.ts index ea95dead9e..0df0d56b67 100644 --- a/hermes-console-vue/src/composables/readiness/use-readiness/useReadiness.ts +++ b/hermes-console-vue/src/composables/readiness/use-readiness/useReadiness.ts @@ -1,5 +1,10 @@ -import { fetchReadiness as getReadiness } from '@/api/hermes-client'; +import { + fetchReadiness as getReadiness, + switchReadiness, +} from '@/api/hermes-client'; import { ref } from 'vue'; +import { useGlobalI18n } from '@/i18n'; +import { useNotificationsStore } from '@/store/app-notifications/useAppNotifications'; import type { DatacenterReadiness } from '@/api/datacenter-readiness'; import type { Ref } from 'vue'; @@ -7,6 +12,10 @@ export interface UseReadiness { datacentersReadiness: Ref; loading: Ref; error: Ref; + switchReadinessState: ( + datacenter: string, + desiredState: boolean, + ) => Promise; } export interface UseReadinessErrors { @@ -14,6 +23,8 @@ export interface UseReadinessErrors { } export function useReadiness(): UseReadiness { + const notificationStore = useNotificationsStore(); + const datacentersReadiness = ref(); const error = ref({ fetchReadiness: null, @@ -31,11 +42,37 @@ export function useReadiness(): UseReadiness { } }; + const switchReadinessState = async ( + datacenter: string, + desiredState: boolean, + ): Promise => { + try { + await switchReadiness(datacenter, desiredState); + notificationStore.dispatchNotification({ + text: useGlobalI18n().t('notifications.readiness.switch.success', { + datacenter, + }), + type: 'success', + }); + return true; + } catch (e) { + notificationStore.dispatchNotification({ + title: useGlobalI18n().t('notifications.readiness.switch.failure', { + datacenter, + }), + text: (e as Error).message, + type: 'error', + }); + return false; + } + }; + fetchReadiness(); return { datacentersReadiness, loading, error, + switchReadinessState, }; } diff --git a/hermes-console-vue/src/composables/roles/use-roles/useRoles.spec.ts b/hermes-console-vue/src/composables/roles/use-roles/useRoles.spec.ts index 9849da6bba..712214a324 100644 --- a/hermes-console-vue/src/composables/roles/use-roles/useRoles.spec.ts +++ b/hermes-console-vue/src/composables/roles/use-roles/useRoles.spec.ts @@ -1,6 +1,12 @@ import { afterEach } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; import { dummyRoles } from '@/dummy/roles'; +import { + expectNotificationDispatched, + notificationStoreSpy, +} from '@/utils/test-utils'; import { fetchRolesErrorHandler, fetchRolesHandler } from '@/mocks/handlers'; +import { setActivePinia } from 'pinia'; import { setupServer } from 'msw/node'; import { useRoles } from '@/composables/roles/use-roles/useRoles'; import { waitFor } from '@testing-library/vue'; @@ -12,6 +18,14 @@ describe('useRoles', () => { fetchRolesHandler({ roles: dummyRoles, path: '/roles' }), ); + const pinia = createTestingPinia({ + fakeApp: true, + }); + + beforeEach(() => { + setActivePinia(pinia); + }); + afterEach(() => { server.resetHandlers(); }); @@ -83,6 +97,7 @@ describe('useRoles', () => { // given server.use(fetchRolesErrorHandler({ errorCode: 500, path: '/roles' })); server.listen(); + const notificationStore = notificationStoreSpy(); // when const { loading, error } = useRoles(null, null); @@ -91,6 +106,10 @@ describe('useRoles', () => { await waitFor(() => { expect(loading.value).toBeFalsy(); expect(error.value.fetchRoles).not.toBeNull(); + expectNotificationDispatched(notificationStore, { + type: 'warning', + text: 'notifications.roles.fetch.failure', + }); }); }); }); diff --git a/hermes-console-vue/src/composables/roles/use-roles/useRoles.ts b/hermes-console-vue/src/composables/roles/use-roles/useRoles.ts index 14c5da62aa..d6e08e5cc9 100644 --- a/hermes-console-vue/src/composables/roles/use-roles/useRoles.ts +++ b/hermes-console-vue/src/composables/roles/use-roles/useRoles.ts @@ -1,5 +1,7 @@ import { fetchRoles as getRoles } from '@/api/hermes-client'; import { ref } from 'vue'; +import { useGlobalI18n } from '@/i18n'; +import { useNotificationsStore } from '@/store/app-notifications/useAppNotifications'; import type { Ref } from 'vue'; import type { Role } from '@/api/role'; @@ -17,6 +19,8 @@ export function useRoles( topicName: string | null, subscriptionName: string | null, ): UseRoles { + const notificationStore = useNotificationsStore(); + const roles = ref(); const error = ref({ fetchRoles: null, @@ -30,7 +34,10 @@ export function useRoles( ).data; } catch (e) { error.value.fetchRoles = e as Error; - // TODO should send notification + notificationStore.dispatchNotification({ + text: useGlobalI18n().t('notifications.roles.fetch.failure'), + type: 'warning', + }); } finally { loading.value = false; } diff --git a/hermes-console-vue/src/composables/search/useSearch.ts b/hermes-console-vue/src/composables/search/useSearch.ts index e21275f372..bd9cb16746 100644 --- a/hermes-console-vue/src/composables/search/useSearch.ts +++ b/hermes-console-vue/src/composables/search/useSearch.ts @@ -68,11 +68,14 @@ export function useSearch(): UseSearch { }; } +const patternPrefix = '(?i).*'; +const patternSuffix = '.*'; + function buildQuery(filter: SearchFilter, pattern: string): Object { return { query: { [filter]: { - like: pattern, + like: `${patternPrefix}${pattern}${patternSuffix}`, }, }, }; diff --git a/hermes-console-vue/src/composables/use-stats/useStats.spec.ts b/hermes-console-vue/src/composables/stats/use-stats/useStats.spec.ts similarity index 98% rename from hermes-console-vue/src/composables/use-stats/useStats.spec.ts rename to hermes-console-vue/src/composables/stats/use-stats/useStats.spec.ts index 6c245bc823..eaba154479 100644 --- a/hermes-console-vue/src/composables/use-stats/useStats.spec.ts +++ b/hermes-console-vue/src/composables/stats/use-stats/useStats.spec.ts @@ -2,7 +2,7 @@ import { afterEach } from 'vitest'; import { fetchStatsErrorHandler, fetchStatsHandler } from '@/mocks/handlers'; import { setupServer } from 'msw/node'; import { statsResponse } from '@/dummy/stats'; -import { useStats } from '@/composables/use-stats/useStats'; +import { useStats } from '@/composables/stats/use-stats/useStats'; import { waitFor } from '@testing-library/vue'; describe('useStats', () => { diff --git a/hermes-console-vue/src/composables/use-stats/useStats.ts b/hermes-console-vue/src/composables/stats/use-stats/useStats.ts similarity index 100% rename from hermes-console-vue/src/composables/use-stats/useStats.ts rename to hermes-console-vue/src/composables/stats/use-stats/useStats.ts diff --git a/hermes-console-vue/src/composables/subscription/use-subscription/useSubscription.spec.ts b/hermes-console-vue/src/composables/subscription/use-subscription/useSubscription.spec.ts index 519f80e169..e78f06be4d 100644 --- a/hermes-console-vue/src/composables/subscription/use-subscription/useSubscription.spec.ts +++ b/hermes-console-vue/src/composables/subscription/use-subscription/useSubscription.spec.ts @@ -1,62 +1,51 @@ -import { beforeEach } from 'vitest'; +import { afterEach } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; import { dummySubscription, dummySubscriptionHealth, dummySubscriptionMetrics, - dummyUndeliveredMessage, - dummyUndeliveredMessages, } from '@/dummy/subscription'; +import { expect } from 'vitest'; +import { + expectNotificationDispatched, + notificationStoreSpy, +} from '@/utils/test-utils'; +import { + fetchSubscriptionErrorHandler, + fetchSubscriptionHealthErrorHandler, + fetchSubscriptionLastUndeliveredMessageErrorHandler, + fetchSubscriptionMetricsErrorHandler, + fetchSubscriptionUndeliveredMessagesErrorHandler, + removeSubscriptionErrorHandler, + removeSubscriptionHandler, + subscriptionStateErrorHandler, + subscriptionStateHandler, + successfulSubscriptionHandlers, +} from '@/mocks/handlers'; +import { setActivePinia } from 'pinia'; +import { setupServer } from 'msw/node'; import { useSubscription } from '@/composables/subscription/use-subscription/useSubscription'; import { waitFor } from '@testing-library/vue'; -import axios from 'axios'; -import type { Mocked } from 'vitest'; - -vitest.mock('axios'); -const mockedAxios = axios as Mocked; +import type { UseSubscriptionsErrors } from '@/composables/subscription/use-subscription/useSubscription'; describe('useSubscription', () => { - beforeEach(() => { - vitest.resetAllMocks(); - }); + const server = setupServer(...successfulSubscriptionHandlers); - it('should hit expected Hermes API endpoints', async () => { - // given - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscription }); - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscriptionMetrics }); - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscriptionHealth }); - mockedAxios.get.mockResolvedValueOnce({ data: dummyUndeliveredMessages }); - mockedAxios.get.mockResolvedValueOnce({ data: dummyUndeliveredMessage }); + const pinia = createTestingPinia({ + fakeApp: true, + }); - // when - useSubscription('topic', 'subscription'); + beforeEach(() => { + setActivePinia(pinia); + }); - // then - await waitFor(() => { - expect(mockedAxios.get.mock.calls[0][0]).toBe( - '/topics/topic/subscriptions/subscription', - ); - expect(mockedAxios.get.mock.calls[1][0]).toBe( - '/topics/topic/subscriptions/subscription/metrics', - ); - expect(mockedAxios.get.mock.calls[2][0]).toBe( - '/topics/topic/subscriptions/subscription/health', - ); - expect(mockedAxios.get.mock.calls[3][0]).toBe( - '/topics/topic/subscriptions/subscription/undelivered', - ); - expect(mockedAxios.get.mock.calls[4][0]).toBe( - '/topics/topic/subscriptions/subscription/undelivered/last', - ); - }); + afterEach(() => { + server.resetHandlers(); }); it('should fetch subscription details from Hermes API', async () => { // given - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscription }); - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscriptionMetrics }); - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscriptionHealth }); - mockedAxios.get.mockResolvedValueOnce({ data: dummyUndeliveredMessages }); - mockedAxios.get.mockResolvedValueOnce({ data: dummyUndeliveredMessage }); + server.listen(); // when const { @@ -65,14 +54,14 @@ describe('useSubscription', () => { subscriptionHealth, loading, error, - } = useSubscription('topic', 'subscription'); + } = useSubscription(dummySubscription.topicName, dummySubscription.name); // then expect(loading.value).toBeTruthy(); await waitFor(() => { expect(loading.value).toBeFalsy(); - expect(error.value).toBeFalsy(); + expectNoErrors(error.value); expect(subscription.value).toEqual(dummySubscription); expect(subscriptionMetrics.value).toEqual(dummySubscriptionMetrics); expect(subscriptionHealth.value).toEqual(dummySubscriptionHealth); @@ -81,11 +70,8 @@ describe('useSubscription', () => { it('should set error to true on subscription endpoint failure', async () => { // given - mockedAxios.get.mockRejectedValueOnce({}); - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscriptionMetrics }); - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscriptionHealth }); - mockedAxios.get.mockResolvedValueOnce({ data: dummyUndeliveredMessages }); - mockedAxios.get.mockResolvedValueOnce({ data: dummyUndeliveredMessage }); + server.use(fetchSubscriptionErrorHandler({ errorCode: 500 })); + server.listen(); // when const { loading, error } = useSubscription('topic', 'subscription'); @@ -93,85 +79,289 @@ describe('useSubscription', () => { // then await waitFor(() => { expect(loading.value).toBeFalsy(); - expect(error.value).toBeTruthy(); + expect(error.value.fetchSubscription).not.toBeNull(); }); }); it('should set error to true on subscription metrics endpoint failure', async () => { // given - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscription }); - mockedAxios.get.mockRejectedValueOnce({}); - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscriptionHealth }); - mockedAxios.get.mockResolvedValueOnce({ data: dummyUndeliveredMessages }); - mockedAxios.get.mockResolvedValueOnce({ data: dummyUndeliveredMessage }); + server.use(fetchSubscriptionMetricsErrorHandler({ errorCode: 500 })); + server.listen(); // when - const { loading, error } = useSubscription('topic', 'subscription'); + const { loading, error } = useSubscription( + dummySubscription.topicName, + dummySubscription.name, + ); // then await waitFor(() => { expect(loading.value).toBeFalsy(); - expect(error.value).toBeTruthy(); + expect(error.value.fetchSubscriptionMetrics).not.toBeNull(); }); }); it('should set error to true on subscription health endpoint failure', async () => { // given - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscription }); - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscriptionMetrics }); - mockedAxios.get.mockRejectedValueOnce({}); - mockedAxios.get.mockResolvedValueOnce({ data: dummyUndeliveredMessages }); - mockedAxios.get.mockResolvedValueOnce({ data: dummyUndeliveredMessage }); + server.use(fetchSubscriptionHealthErrorHandler({ errorCode: 500 })); + server.listen(); // when - const { loading, error } = useSubscription('topic', 'subscription'); + const { loading, error } = useSubscription( + dummySubscription.topicName, + dummySubscription.name, + ); // then await waitFor(() => { expect(loading.value).toBeFalsy(); - expect(error.value).toBeTruthy(); + expect(error.value.fetchSubscriptionHealth).not.toBeNull(); }); }); it('should set last undelivered message to `null` on backend HTTP 404', async () => { // given - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscription }); - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscriptionMetrics }); - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscriptionHealth }); - mockedAxios.get.mockResolvedValueOnce({ data: dummyUndeliveredMessages }); - mockedAxios.get.mockRejectedValueOnce({}); + server.use( + fetchSubscriptionLastUndeliveredMessageErrorHandler({ errorCode: 404 }), + ); + server.listen(); // when const { subscriptionLastUndeliveredMessage, loading, error } = - useSubscription('topic', 'subscription'); + useSubscription(dummySubscription.topicName, dummySubscription.name); // then await waitFor(() => { expect(loading.value).toBeFalsy(); - expect(error.value).toBeFalsy(); + expect( + error.value.fetchSubscriptionLastUndeliveredMessage, + ).not.toBeNull(); expect(subscriptionLastUndeliveredMessage.value).toBeNull(); }); }); it('should ignore undelivered endpoint failure and set empty list', async () => { // given - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscription }); - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscriptionMetrics }); - mockedAxios.get.mockResolvedValueOnce({ data: dummySubscriptionHealth }); - mockedAxios.get.mockRejectedValueOnce({}); - mockedAxios.get.mockResolvedValueOnce({ data: dummyUndeliveredMessage }); + server.use( + fetchSubscriptionUndeliveredMessagesErrorHandler({ errorCode: 404 }), + ); + server.listen(); // when const { subscriptionUndeliveredMessages, loading, error } = useSubscription( - 'topic', - 'subscription', + dummySubscription.topicName, + dummySubscription.name, ); // then await waitFor(() => { expect(loading.value).toBeFalsy(); - expect(error.value).toBeFalsy(); + expect(error.value.fetchSubscriptionUndeliveredMessages).not.toBeNull(); expect(subscriptionUndeliveredMessages.value).toEqual([]); }); }); + + it('should show message that removing subscription was successful', async () => { + // given + server.use( + removeSubscriptionHandler({ + topic: dummySubscription.topicName, + subscription: dummySubscription.name, + }), + ); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { removeSubscription } = useSubscription( + dummySubscription.topicName, + dummySubscription.name, + ); + + // when + await removeSubscription(); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'success', + text: 'notifications.subscription.delete.success', + }); + }); + }); + + it('should show message that removing subscription was unsuccessful', async () => { + // given + server.use( + removeSubscriptionErrorHandler({ + topic: dummySubscription.topicName, + subscription: dummySubscription.name, + errorCode: 500, + }), + ); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { removeSubscription } = useSubscription( + dummySubscription.topicName, + dummySubscription.name, + ); + + // when + await removeSubscription(); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'error', + title: 'notifications.subscription.delete.failure', + }); + }); + }); + + it('should show message that suspending subscription was successful', async () => { + // given + server.use( + subscriptionStateHandler({ + topic: dummySubscription.topicName, + subscription: dummySubscription.name, + }), + ); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { suspendSubscription } = useSubscription( + dummySubscription.topicName, + dummySubscription.name, + ); + + // when + await suspendSubscription(); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'success', + text: 'notifications.subscription.suspend.success', + }); + }); + }); + + it('should show message that suspending subscription was unsuccessful', async () => { + // given + server.use( + subscriptionStateErrorHandler({ + topic: dummySubscription.topicName, + subscription: dummySubscription.name, + errorCode: 500, + }), + ); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { suspendSubscription } = useSubscription( + dummySubscription.topicName, + dummySubscription.name, + ); + + // when + await suspendSubscription(); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'error', + title: 'notifications.subscription.suspend.failure', + }); + }); + }); + + it('should show message that activating subscription was successful', async () => { + // given + server.use( + subscriptionStateHandler({ + topic: dummySubscription.topicName, + subscription: dummySubscription.name, + }), + ); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { activateSubscription } = useSubscription( + dummySubscription.topicName, + dummySubscription.name, + ); + + // when + await activateSubscription(); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'success', + text: 'notifications.subscription.activate.success', + }); + }); + }); + + it('should show message that activating subscription was unsuccessful', async () => { + // given + server.use( + subscriptionStateErrorHandler({ + topic: dummySubscription.topicName, + subscription: dummySubscription.name, + errorCode: 500, + }), + ); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { activateSubscription } = useSubscription( + dummySubscription.topicName, + dummySubscription.name, + ); + + // when + await activateSubscription(); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'error', + title: 'notifications.subscription.activate.failure', + }); + }); + }); }); + +function expectErrors( + errors: UseSubscriptionsErrors, + { + fetchSubscription = false, + fetchOwner = false, + fetchSubscriptionMetrics = false, + fetchSubscriptionHealth = false, + fetchSubscriptionUndeliveredMessages = false, + fetchSubscriptionLastUndeliveredMessage = false, + }, +) { + (fetchSubscription && expect(errors.fetchSubscription).not.toBeNull()) || + expect(errors.fetchSubscription).toBeNull(); + (fetchOwner && expect(errors.fetchOwner).not.toBeNull()) || + expect(errors.fetchOwner).toBeNull(); + (fetchSubscriptionMetrics && + expect(errors.fetchSubscriptionMetrics).not.toBeNull()) || + expect(errors.fetchSubscriptionMetrics).toBeNull(); + (fetchSubscriptionHealth && + expect(errors.fetchSubscriptionHealth).not.toBeNull()) || + expect(errors.fetchSubscriptionHealth).toBeNull(); + (fetchSubscriptionUndeliveredMessages && + expect(errors.fetchSubscriptionUndeliveredMessages).not.toBeNull()) || + expect(errors.fetchSubscriptionUndeliveredMessages).toBeNull(); + (fetchSubscriptionLastUndeliveredMessage && + expect(errors.fetchSubscriptionLastUndeliveredMessage).not.toBeNull()) || + expect(errors.fetchSubscriptionLastUndeliveredMessage).toBeNull(); +} + +function expectNoErrors(errors: UseSubscriptionsErrors) { + expectErrors(errors, {}); +} diff --git a/hermes-console-vue/src/composables/subscription/use-subscription/useSubscription.ts b/hermes-console-vue/src/composables/subscription/use-subscription/useSubscription.ts index bb8c24891f..52dad90eed 100644 --- a/hermes-console-vue/src/composables/subscription/use-subscription/useSubscription.ts +++ b/hermes-console-vue/src/composables/subscription/use-subscription/useSubscription.ts @@ -1,94 +1,227 @@ -import { computed, ref } from 'vue'; -import axios from 'axios'; +import { + activateSubscription as activate, + removeSubscription as deleteSubscription, + fetchOwner as getOwner, + fetchSubscription as getSubscription, + fetchSubscriptionHealth as getSubscriptionHealth, + fetchSubscriptionLastUndeliveredMessage as getSubscriptionLastUndeliveredMessage, + fetchSubscriptionMetrics as getSubscriptionMetrics, + fetchSubscriptionUndeliveredMessages as getSubscriptionUndeliveredMessages, + suspendSubscription as suspend, +} from '@/api/hermes-client'; +import { ref } from 'vue'; +import { useGlobalI18n } from '@/i18n'; +import { useNotificationsStore } from '@/store/app-notifications/useAppNotifications'; +import type { Owner } from '@/api/owner'; +import type { Ref } from 'vue'; import type { SentMessageTrace } from '@/api/subscription-undelivered'; import type { Subscription } from '@/api/subscription'; import type { SubscriptionHealth } from '@/api/subscription-health'; import type { SubscriptionMetrics } from '@/api/subscription-metrics'; -export function useSubscription(topicName: string, subscriptionName: string) { +export interface UseSubscription { + subscription: Ref; + owner: Ref; + subscriptionMetrics: Ref; + subscriptionHealth: Ref; + subscriptionUndeliveredMessages: Ref; + subscriptionLastUndeliveredMessage: Ref; + loading: Ref; + error: Ref; + removeSubscription: () => Promise; + suspendSubscription: () => Promise; + activateSubscription: () => Promise; +} + +export interface UseSubscriptionsErrors { + fetchSubscription: Error | null; + fetchOwner: Error | null; + fetchSubscriptionMetrics: Error | null; + fetchSubscriptionHealth: Error | null; + fetchSubscriptionUndeliveredMessages: Error | null; + fetchSubscriptionLastUndeliveredMessage: Error | null; +} + +export function useSubscription( + topicName: string, + subscriptionName: string, +): UseSubscription { + const notificationStore = useNotificationsStore(); + const subscription = ref(); + const owner = ref(); const subscriptionMetrics = ref(); const subscriptionHealth = ref(); - const subscriptionUndeliveredMessages = ref(); - const subscriptionLastUndeliveredMessage = ref(); - - const error = ref(false); - - const loading = computed( - () => - !error.value && - !( - subscription.value && - subscriptionMetrics.value && - subscriptionHealth.value && - subscriptionUndeliveredMessages.value && - subscriptionLastUndeliveredMessage.value !== undefined - ), - ); - - function fetchSubscription() { - axios - .get( - `/topics/${topicName}/subscriptions/${subscriptionName}`, - ) - .then((response) => (subscription.value = response.data)) - .catch(() => (error.value = true)); - } - - function fetchSubscriptionMetrics() { - axios - .get( - `/topics/${topicName}/subscriptions/${subscriptionName}/metrics`, - ) - .then((response) => (subscriptionMetrics.value = response.data)) - .catch(() => (error.value = true)); - } - - function fetchSubscriptionHealth() { - axios - .get( - `/topics/${topicName}/subscriptions/${subscriptionName}/health`, - ) - .then((response) => (subscriptionHealth.value = response.data)) - .catch(() => (error.value = true)); - } - - function fetchSubscriptionUndeliveredMessages() { - axios - .get( - `/topics/${topicName}/subscriptions/${subscriptionName}/undelivered`, - ) - .then( - (response) => (subscriptionUndeliveredMessages.value = response.data), - ) - .catch(() => (subscriptionUndeliveredMessages.value = [])); - } - - function fetchSubscriptionLastUndeliveredMessage() { - axios - .get( - `/topics/${topicName}/subscriptions/${subscriptionName}/undelivered/last`, - ) - .then( - (response) => - (subscriptionLastUndeliveredMessage.value = response.data), - ) - .catch(() => (subscriptionLastUndeliveredMessage.value = null)); - } + const subscriptionUndeliveredMessages = ref([]); + const subscriptionLastUndeliveredMessage = ref(null); + const loading = ref(false); + const error = ref({ + fetchSubscription: null, + fetchOwner: null, + fetchSubscriptionMetrics: null, + fetchSubscriptionHealth: null, + fetchSubscriptionUndeliveredMessages: null, + fetchSubscriptionLastUndeliveredMessage: null, + }); + + const fetchSubscription = async () => { + try { + loading.value = true; + await fetchSubscriptionInfo(); + if (subscription.value) { + await Promise.allSettled([ + fetchSubscriptionOwner(subscription.value.owner.id), + fetchSubscriptionMetrics(), + fetchSubscriptionHealth(), + fetchSubscriptionUndeliveredMessages(), + fetchSubscriptionLastUndeliveredMessage(), + ]); + } + } finally { + loading.value = false; + } + }; + + const fetchSubscriptionInfo = async () => { + try { + subscription.value = ( + await getSubscription(topicName, subscriptionName) + ).data; + } catch (e) { + error.value.fetchSubscription = e as Error; + } + }; + + const fetchSubscriptionOwner = async (ownerId: string) => { + try { + owner.value = (await getOwner(ownerId)).data; + } catch (e) { + error.value.fetchOwner = e as Error; + } + }; + + const fetchSubscriptionMetrics = async () => { + try { + subscriptionMetrics.value = ( + await getSubscriptionMetrics(topicName, subscriptionName) + ).data; + } catch (e) { + error.value.fetchSubscriptionMetrics = e as Error; + } + }; + + const fetchSubscriptionHealth = async () => { + try { + subscriptionHealth.value = ( + await getSubscriptionHealth(topicName, subscriptionName) + ).data; + } catch (e) { + error.value.fetchSubscriptionHealth = e as Error; + } + }; + + const fetchSubscriptionUndeliveredMessages = async () => { + try { + subscriptionUndeliveredMessages.value = ( + await getSubscriptionUndeliveredMessages(topicName, subscriptionName) + ).data; + } catch (e) { + error.value.fetchSubscriptionUndeliveredMessages = e as Error; + } + }; + + const fetchSubscriptionLastUndeliveredMessage = async () => { + try { + subscriptionLastUndeliveredMessage.value = ( + await getSubscriptionLastUndeliveredMessage(topicName, subscriptionName) + ).data; + } catch (e) { + error.value.fetchSubscriptionLastUndeliveredMessage = e as Error; + } + }; + + const removeSubscription = async (): Promise => { + try { + await deleteSubscription(topicName, subscriptionName); + notificationStore.dispatchNotification({ + text: useGlobalI18n().t('notifications.subscription.delete.success', { + subscriptionName, + }), + type: 'success', + }); + return true; + } catch (e) { + notificationStore.dispatchNotification({ + title: useGlobalI18n().t('notifications.subscription.delete.failure', { + subscriptionName, + }), + text: (e as Error).message, + type: 'error', + }); + return false; + } + }; + + const suspendSubscription = async (): Promise => { + try { + await suspend(topicName, subscriptionName); + notificationStore.dispatchNotification({ + text: useGlobalI18n().t('notifications.subscription.suspend.success', { + subscriptionName, + }), + type: 'success', + }); + return true; + } catch (e) { + notificationStore.dispatchNotification({ + title: useGlobalI18n().t('notifications.subscription.suspend.failure', { + subscriptionName, + }), + text: (e as Error).message, + type: 'error', + }); + return false; + } + }; + + const activateSubscription = async (): Promise => { + try { + await activate(topicName, subscriptionName); + notificationStore.dispatchNotification({ + text: useGlobalI18n().t('notifications.subscription.activate.success', { + subscriptionName, + }), + type: 'success', + }); + return true; + } catch (e) { + notificationStore.dispatchNotification({ + title: useGlobalI18n().t( + 'notifications.subscription.activate.failure', + { + subscriptionName, + }, + ), + text: (e as Error).message, + type: 'error', + }); + return false; + } + }; fetchSubscription(); - fetchSubscriptionMetrics(); - fetchSubscriptionHealth(); - fetchSubscriptionUndeliveredMessages(); - fetchSubscriptionLastUndeliveredMessage(); return { subscription, + owner, subscriptionMetrics, subscriptionHealth, subscriptionUndeliveredMessages, subscriptionLastUndeliveredMessage, loading, error, + removeSubscription, + suspendSubscription, + activateSubscription, }; } diff --git a/hermes-console-vue/src/composables/topic/use-topic/useTopic.spec.ts b/hermes-console-vue/src/composables/topic/use-topic/useTopic.spec.ts index cbcaafd22b..f002d8c8cd 100644 --- a/hermes-console-vue/src/composables/topic/use-topic/useTopic.spec.ts +++ b/hermes-console-vue/src/composables/topic/use-topic/useTopic.spec.ts @@ -1,32 +1,49 @@ import { afterEach, describe, expect } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; import { - dummySubscription, - secondDummySubscription, -} from '@/dummy/subscription'; -import { + dummyOwner, dummyTopic, dummyTopicMessagesPreview, dummyTopicMetrics, - dummyTopicOwner, } from '@/dummy/topic'; import { + dummySubscription, + secondDummySubscription, +} from '@/dummy/subscription'; +import { + expectNotificationDispatched, + notificationStoreSpy, +} from '@/utils/test-utils'; +import { + fetchOwnerErrorHandler, fetchTopicErrorHandler, fetchTopicMessagesPreviewErrorHandler, fetchTopicMetricsErrorHandler, - fetchTopicOwnerErrorHandler, fetchTopicSubscriptionDetailsErrorHandler, fetchTopicSubscriptionsErrorHandler, + removeTopicErrorHandler, + removeTopicHandler, successfulTopicHandlers, } from '@/mocks/handlers'; +import { setActivePinia } from 'pinia'; import { setupServer } from 'msw/node'; import { useTopic } from '@/composables/topic/use-topic/useTopic'; +import { waitFor } from '@testing-library/vue'; import type { UseTopicErrors } from '@/composables/topic/use-topic/useTopic'; describe('useTopic', () => { const server = setupServer(...successfulTopicHandlers); const topicName = dummyTopic.name; - const topicOwner = dummyTopicOwner.id; + const topicOwner = dummyOwner.id; + + const pinia = createTestingPinia({ + fakeApp: true, + }); + + beforeEach(() => { + setActivePinia(pinia); + }); afterEach(() => { server.resetHandlers(); @@ -61,7 +78,7 @@ describe('useTopic', () => { // and: correct data was returned await topicPromise; expect(topic.value).toEqual(dummyTopic); - expect(owner.value).toEqual(dummyTopicOwner); + expect(owner.value).toEqual(dummyOwner); expect(metrics.value).toEqual(dummyTopicMetrics); expect(messages.value).toEqual(dummyTopicMessagesPreview); expect(subscriptions.value).toEqual([ @@ -76,7 +93,7 @@ describe('useTopic', () => { const expectedDataForErrorTest = { expectedTopic: dummyTopic, - expectedOwner: dummyTopicOwner, + expectedOwner: dummyOwner, expectedMessages: dummyTopicMessagesPreview, expectedMetrics: dummyTopicMetrics, expectedSubscriptions: [dummySubscription, secondDummySubscription], @@ -93,7 +110,7 @@ describe('useTopic', () => { expectedSubscriptions: undefined, }, { - mockHandler: fetchTopicOwnerErrorHandler({ topicOwner }), + mockHandler: fetchOwnerErrorHandler({ owner: topicOwner }), expectedErrors: { fetchOwner: true }, ...expectedDataForErrorTest, expectedOwner: undefined, @@ -173,6 +190,48 @@ describe('useTopic', () => { expectErrors(error.value, expectedErrors); }, ); + + it('should show message that removing topic was successful', async () => { + // given + server.use(removeTopicHandler({ topic: dummyTopic.name })); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { removeTopic } = useTopic(dummyTopic.name); + + // when + await removeTopic(); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'success', + text: 'notifications.topic.delete.success', + }); + }); + }); + + it('should show message that removing topic was unsuccessful', async () => { + // given + server.use( + removeTopicErrorHandler({ topic: dummyTopic.name, errorCode: 500 }), + ); + server.listen(); + const notificationStore = notificationStoreSpy(); + + const { removeTopic } = useTopic(dummyTopic.name); + + // when + await removeTopic(); + + // then + await waitFor(() => { + expectNotificationDispatched(notificationStore, { + type: 'error', + title: 'notifications.topic.delete.failure', + }); + }); + }); }); function expectErrors( diff --git a/hermes-console-vue/src/composables/topic/use-topic/useTopic.ts b/hermes-console-vue/src/composables/topic/use-topic/useTopic.ts index 5f5d8ff6a1..1e54cb3cf1 100644 --- a/hermes-console-vue/src/composables/topic/use-topic/useTopic.ts +++ b/hermes-console-vue/src/composables/topic/use-topic/useTopic.ts @@ -1,13 +1,16 @@ import { + removeTopic as deleteTopic, fetchOfflineClientsSource as getOfflineClientsSource, fetchTopic as getTopic, fetchTopicMessagesPreview as getTopicMessagesPreview, fetchTopicMetrics as getTopicMetrics, - fetchTopicOwner as getTopicOwner, + fetchOwner as getTopicOwner, fetchTopicSubscriptionDetails as getTopicSubscriptionDetails, fetchTopicSubscriptions as getTopicSubscriptions, } from '@/api/hermes-client'; import { ref } from 'vue'; +import { useGlobalI18n } from '@/i18n'; +import { useNotificationsStore } from '@/store/app-notifications/useAppNotifications'; import type { MessagePreview, TopicMetrics, @@ -29,6 +32,7 @@ export interface UseTopic { error: Ref; fetchTopic: () => Promise; fetchOfflineClientsSource: () => Promise; + removeTopic: () => Promise; } export interface UseTopicErrors { @@ -41,6 +45,8 @@ export interface UseTopicErrors { } export function useTopic(topicName: string): UseTopic { + const notificationStore = useNotificationsStore(); + const topic = ref(); const owner = ref(); const messages = ref(); @@ -142,6 +148,28 @@ export function useTopic(topicName: string): UseTopic { } }; + const removeTopic = async (): Promise => { + try { + await deleteTopic(topicName); + notificationStore.dispatchNotification({ + text: useGlobalI18n().t('notifications.topic.delete.success', { + topicName, + }), + type: 'success', + }); + return true; + } catch (e) { + notificationStore.dispatchNotification({ + title: useGlobalI18n().t('notifications.topic.delete.failure', { + topicName, + }), + text: (e as Error).message, + type: 'error', + }); + return false; + } + }; + return { topic, owner, @@ -153,5 +181,6 @@ export function useTopic(topicName: string): UseTopic { error, fetchTopic, fetchOfflineClientsSource, + removeTopic, }; } diff --git a/hermes-console-vue/src/utils/jwt-utils.ts b/hermes-console-vue/src/dummy/jwt-tokens.ts similarity index 80% rename from hermes-console-vue/src/utils/jwt-utils.ts rename to hermes-console-vue/src/dummy/jwt-tokens.ts index b5d85b5f79..4c9b3e166f 100644 --- a/hermes-console-vue/src/utils/jwt-utils.ts +++ b/hermes-console-vue/src/dummy/jwt-tokens.ts @@ -1,4 +1,4 @@ -export const validToken: string = +export const dummyValidToken: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxOTk2MjM5MDIyfQ.3CP-EemSMKu8O2cZNJCAT4PUCiolDcHIx3Du7EpR8EY'; -export const expiredToken: string = +export const dummyExpiredToken: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxOTYyMzkwMjJ9.dqPw8DoRNgDoV68oprIwJG6546VccGfnoPgyMOGT8yQ'; diff --git a/hermes-console-vue/src/dummy/store.ts b/hermes-console-vue/src/dummy/store.ts index b6a9347881..3c4c940f6a 100644 --- a/hermes-console-vue/src/dummy/store.ts +++ b/hermes-console-vue/src/dummy/store.ts @@ -3,6 +3,7 @@ import { dummyAppConfig } from '@/dummy/app-config'; import type { AppConfigStoreState } from '@/store/app-config/types'; import type { AuthStoreState } from '@/store/auth/types'; import type { ConsistencyStoreState } from '@/store/consistency/types'; +import type { NotificationsState } from '@/store/app-notifications/types'; export const appConfigStoreState: AppConfigStoreState = { appConfig: dummyAppConfig, @@ -12,6 +13,10 @@ export const appConfigStoreState: AppConfigStoreState = { }, }; +export const notificationsStoreState: NotificationsState = { + notifications: [], +}; + export const authStoreState: AuthStoreState = { accessToken: '', codeVerifier: '', diff --git a/hermes-console-vue/src/dummy/subscription.ts b/hermes-console-vue/src/dummy/subscription.ts index d571e0660a..ab3317fda9 100644 --- a/hermes-console-vue/src/dummy/subscription.ts +++ b/hermes-console-vue/src/dummy/subscription.ts @@ -34,7 +34,7 @@ export const dummySubscription: Subscription = { trackingMode: 'trackingOff', owner: { source: 'Service Catalog', - id: '42', + id: '41', }, monitoringDetails: { severity: Severity.NON_IMPORTANT, diff --git a/hermes-console-vue/src/dummy/topic.ts b/hermes-console-vue/src/dummy/topic.ts index f3d87ed02b..94d820fc23 100644 --- a/hermes-console-vue/src/dummy/topic.ts +++ b/hermes-console-vue/src/dummy/topic.ts @@ -53,7 +53,7 @@ export const dummyTopic: TopicWithSchema = { modifiedAt: 1636451113.517, }; -export const dummyTopicOwner: Owner = { +export const dummyOwner: Owner = { id: '41', name: 'your-super-service', url: 'https://google.pl?q=your-super-service', diff --git a/hermes-console-vue/src/i18n/en-US.ts b/hermes-console-vue/src/i18n/en-US.ts index b2e2f6ffac..aa77e58654 100644 --- a/hermes-console-vue/src/i18n/en-US.ts +++ b/hermes-console-vue/src/i18n/en-US.ts @@ -13,6 +13,11 @@ const en_US = { signIn: 'Sign in', logout: 'Logout', }, + confirmationDialog: { + confirm: 'Confirm', + cancel: 'Cancel', + confirmText: "Type 'prod' to confirm action.", + }, consistency: { connectionError: { title: 'Connection error', @@ -58,6 +63,12 @@ const en_US = { }, }, inconsistentTopics: { + confirmationDialog: { + remove: { + title: 'Confirm topic deletion', + text: 'Are you sure you want to delete topic {topicToDelete}', + }, + }, noTopics: 'No inconsistent topics found', appliedFilter: '(applied filter: “{filter}”)', heading: 'Topics existing on kafka cluster but not present in hermes', @@ -143,9 +154,15 @@ const en_US = { trackingEnabled: 'Tracking Enabled', }, readiness: { + confirmationDialog: { + switch: { + title: 'Confirm readiness switch', + text: 'Are you sure you want to {switchAction} datacenter {dcToSwitch}?', + }, + }, title: 'Datacenters Readiness', - turnOn: 'Turn on', - turnOff: 'Turn off', + turnOn: 'turn on', + turnOff: 'turn off', index: '#', datacenter: 'Datacenter', isReady: 'Is ready', @@ -161,9 +178,16 @@ const en_US = { }, groups: { actions: { + remove: 'Remove', create: 'New Group', search: 'search…', }, + confirmationDialog: { + remove: { + title: 'Confirm group deletion', + text: 'Are you sure you want to delete group {groupId}', + }, + }, connectionError: { title: 'Connection error', text: 'Could not fetch topic groups', @@ -202,12 +226,19 @@ const en_US = { heading: 'Groups', }, groupTopics: { + title: 'Group', groupTopicsBreadcrumbs: { home: 'home', groups: 'groups', }, }, topicView: { + confirmationDialog: { + remove: { + title: 'Confirm topic deletion', + text: 'Are you sure you want to delete topic {topicName}', + }, + }, header: { topic: 'TOPIC', owner: 'OWNER:', @@ -288,6 +319,20 @@ const en_US = { }, }, subscription: { + confirmationDialog: { + remove: { + title: 'Confirm subscription deletion', + text: 'Are you sure you want to delete subscription {subscriptionId}', + }, + suspend: { + title: 'Confirm subscription suspension', + text: 'Are you sure you want to suspend subscription {subscriptionId}', + }, + activate: { + title: 'Confirm subscription activation', + text: 'Are you sure you want to activate subscription {subscriptionId}', + }, + }, connectionError: { title: 'Connection error', text: 'Could not fetch {subscriptionId} subscription details', @@ -461,7 +506,7 @@ const en_US = { }, subscriptionMetadata: { subscription: 'Subscription', - owners: 'Owners', + owners: 'OWNER:', unauthorizedTooltip: 'Sign in to edit the subscription', actions: { diagnostics: 'Diagnostics', @@ -522,6 +567,50 @@ const en_US = { failure: 'Failed to move offsets for subscription {subscriptionName}', }, }, + readiness: { + switch: { + success: 'Successfully switched datacenter {datacenter} readiness', + failure: "Couldn't switch datacenter {datacenter} readiness", + }, + }, + roles: { + fetch: { + failure: + 'Fetching user roles failed. Some options might not be visible.', + }, + }, + group: { + delete: { + success: 'Group {groupId} successfully deleted', + failure: "Couldn't delete group {groupId}", + }, + }, + topic: { + delete: { + success: 'Topic {topicName} successfully deleted', + failure: "Couldn't delete topic {topicName}", + }, + }, + inconsistentTopic: { + delete: { + success: 'Topic {topic} successfully deleted', + failure: "Couldn't delete topic {topic}", + }, + }, + subscription: { + delete: { + success: 'Subscription {subscriptionName} successfully deleted', + failure: "Couldn't delete subscription {subscriptionName}", + }, + suspend: { + success: 'Subscription {subscriptionName} successfully suspended', + failure: "Couldn't suspend subscription {subscriptionName}", + }, + activate: { + success: 'Subscription {subscriptionName} successfully activated', + failure: "Couldn't activate subscription {subscriptionName}", + }, + }, }, }; diff --git a/hermes-console-vue/src/main.ts b/hermes-console-vue/src/main.ts index 4b5db0086f..ab1307eeee 100644 --- a/hermes-console-vue/src/main.ts +++ b/hermes-console-vue/src/main.ts @@ -7,15 +7,9 @@ import { createPinia } from 'pinia'; import { createVuetify } from 'vuetify'; import { i18n } from '@/i18n'; import App from './App.vue'; -import axios from 'axios'; import piniaPluginPersistedState from 'pinia-plugin-persistedstate'; import router from './router'; -if (import.meta.env.DEV) { - axios.defaults.baseURL = 'http://localhost:3000'; -} -axios.defaults.timeout = 5000; - const vuetify = createVuetify({ theme: { themes: { diff --git a/hermes-console-vue/src/mocks/handlers.ts b/hermes-console-vue/src/mocks/handlers.ts index 2fa2ab335c..2ba6c9f02d 100644 --- a/hermes-console-vue/src/mocks/handlers.ts +++ b/hermes-console-vue/src/mocks/handlers.ts @@ -1,14 +1,18 @@ import { - dummySubscription, - dummyTopicSubscriptionsList, - secondDummySubscription, -} from '@/dummy/subscription'; -import { + dummyOwner, dummyTopic, dummyTopicMessagesPreview, dummyTopicMetrics, - dummyTopicOwner, } from '@/dummy/topic'; +import { + dummySubscription, + dummySubscriptionHealth, + dummySubscriptionMetrics, + dummyTopicSubscriptionsList, + dummyUndeliveredMessage, + dummyUndeliveredMessages, + secondDummySubscription, +} from '@/dummy/subscription'; import { rest } from 'msw'; import type { AccessTokenResponse } from '@/api/access-token-response'; import type { ConstraintsConfig } from '@/api/constraints'; @@ -23,8 +27,11 @@ import type { } from '@/api/topic'; import type { Owner } from '@/api/owner'; import type { Role } from '@/api/role'; +import type { SentMessageTrace } from '@/api/subscription-undelivered'; import type { Stats } from '@/api/stats'; import type { Subscription } from '@/api/subscription'; +import type { SubscriptionHealth } from '@/api/subscription-health'; +import type { SubscriptionMetrics } from '@/api/subscription-metrics'; const url = 'http://localhost:3000'; @@ -48,27 +55,23 @@ export const fetchTopicErrorHandler = ({ return res(ctx.status(errorCode), ctx.json(undefined)); }); -export const fetchTopicOwnerHandler = ({ - topicOwner = dummyTopicOwner, -}: { - topicOwner?: Owner; -}) => +export const fetchOwnerHandler = ({ owner = dummyOwner }: { owner?: Owner }) => rest.get( - `${url}/owners/sources/Service%20Catalog/${topicOwner.id}`, + `${url}/owners/sources/Service%20Catalog/${owner.id}`, (req, res, ctx) => { - return res(ctx.status(200), ctx.json(topicOwner)); + return res(ctx.status(200), ctx.json(owner)); }, ); -export const fetchTopicOwnerErrorHandler = ({ - topicOwner, +export const fetchOwnerErrorHandler = ({ + owner, errorCode = 500, }: { - topicOwner: string; + owner: string; errorCode?: number; }) => rest.get( - `${url}/owners/sources/Service%20Catalog/${topicOwner}`, + `${url}/owners/sources/Service%20Catalog/${owner}`, (req, res, ctx) => { return res(ctx.status(errorCode), ctx.json(undefined)); }, @@ -170,7 +173,7 @@ export const fetchTopicSubscriptionDetailsErrorHandler = ({ export const successfulTopicHandlers = [ fetchTopicHandler({}), - fetchTopicOwnerHandler({}), + fetchOwnerHandler({}), fetchTopicMessagesPreviewHandler({ topicName: dummyTopic.name }), fetchTopicMetricsHandler({ topicName: dummyTopic.name }), fetchTopicSubscriptionsHandler({ topicName: dummyTopic.name }), @@ -180,6 +183,169 @@ export const successfulTopicHandlers = [ }), ]; +export const fetchSubscriptionHandler = ({ + subscription = dummySubscription, +}: { + subscription?: Subscription; +}) => + rest.get( + `${url}/topics/${subscription.topicName}/subscriptions/${subscription.name}`, + (req, res, ctx) => { + return res(ctx.status(200), ctx.json(subscription)); + }, + ); + +export const fetchSubscriptionMetricsHandler = ({ + topicName = dummySubscription.topicName, + subscriptionName = dummySubscription.name, + subscriptionMetrics = dummySubscriptionMetrics, +}: { + topicName?: string; + subscriptionName?: string; + subscriptionMetrics?: SubscriptionMetrics; +}) => + rest.get( + `${url}/topics/${topicName}/subscriptions/${subscriptionName}/metrics`, + (req, res, ctx) => { + return res(ctx.status(200), ctx.json(subscriptionMetrics)); + }, + ); + +export const fetchSubscriptionHealthHandler = ({ + topicName = dummySubscription.topicName, + subscriptionName = dummySubscription.name, + subscriptionHealth = dummySubscriptionHealth, +}: { + topicName?: string; + subscriptionName?: string; + subscriptionHealth?: SubscriptionHealth; +}) => + rest.get( + `${url}/topics/${topicName}/subscriptions/${subscriptionName}/health`, + (req, res, ctx) => { + return res(ctx.status(200), ctx.json(subscriptionHealth)); + }, + ); + +export const fetchSubscriptionUndeliveredMessagesHandler = ({ + topicName = dummySubscription.topicName, + subscriptionName = dummySubscription.name, + subscriptionUndeliveredMessages = dummyUndeliveredMessages, +}: { + topicName?: string; + subscriptionName?: string; + subscriptionUndeliveredMessages?: SentMessageTrace[]; +}) => + rest.get( + `${url}/topics/${topicName}/subscriptions/${subscriptionName}/undelivered`, + (req, res, ctx) => { + return res(ctx.status(200), ctx.json(subscriptionUndeliveredMessages)); + }, + ); + +export const fetchSubscriptionLastUndeliveredMessageHandler = ({ + topicName = dummySubscription.topicName, + subscriptionName = dummySubscription.name, + subscriptionLastUndeliveredMessage = dummyUndeliveredMessage, +}: { + topicName?: string; + subscriptionName?: string; + subscriptionLastUndeliveredMessage?: SentMessageTrace; +}) => + rest.get( + `${url}/topics/${topicName}/subscriptions/${subscriptionName}/undelivered/last`, + (req, res, ctx) => { + return res(ctx.status(200), ctx.json(subscriptionLastUndeliveredMessage)); + }, + ); + +export const fetchSubscriptionErrorHandler = ({ + subscription = dummySubscription, + errorCode = 500, +}: { + subscription?: Subscription; + errorCode?: number; +}) => + rest.get( + `${url}/topics/${subscription.topicName}/subscriptions/${subscription.name}`, + (req, res, ctx) => { + return res(ctx.status(errorCode), ctx.json(undefined)); + }, + ); + +export const fetchSubscriptionMetricsErrorHandler = ({ + topicName = dummySubscription.topicName, + subscriptionName = dummySubscription.name, + errorCode = 500, +}: { + topicName?: string; + subscriptionName?: string; + errorCode?: number; +}) => + rest.get( + `${url}/topics/${topicName}/subscriptions/${subscriptionName}/metrics`, + (req, res, ctx) => { + return res(ctx.status(errorCode), ctx.json(undefined)); + }, + ); + +export const fetchSubscriptionHealthErrorHandler = ({ + topicName = dummySubscription.topicName, + subscriptionName = dummySubscription.name, + errorCode = 500, +}: { + topicName?: string; + subscriptionName?: string; + errorCode?: number; +}) => + rest.get( + `${url}/topics/${topicName}/subscriptions/${subscriptionName}/health`, + (req, res, ctx) => { + return res(ctx.status(errorCode), ctx.json(undefined)); + }, + ); + +export const fetchSubscriptionUndeliveredMessagesErrorHandler = ({ + topicName = dummySubscription.topicName, + subscriptionName = dummySubscription.name, + errorCode = 500, +}: { + topicName?: string; + subscriptionName?: string; + errorCode?: number; +}) => + rest.get( + `${url}/topics/${topicName}/subscriptions/${subscriptionName}/undelivered`, + (req, res, ctx) => { + return res(ctx.status(errorCode), ctx.json(undefined)); + }, + ); + +export const fetchSubscriptionLastUndeliveredMessageErrorHandler = ({ + topicName = dummySubscription.topicName, + subscriptionName = dummySubscription.name, + errorCode = 500, +}: { + topicName?: string; + subscriptionName?: string; + errorCode?: number; +}) => + rest.get( + `${url}/topics/${topicName}/subscriptions/${subscriptionName}/undelivered/last`, + (req, res, ctx) => { + return res(ctx.status(errorCode), ctx.json(undefined)); + }, + ); + +export const successfulSubscriptionHandlers = [ + fetchSubscriptionHandler({}), + fetchOwnerHandler({}), + fetchSubscriptionMetricsHandler({}), + fetchSubscriptionHealthHandler({}), + fetchSubscriptionUndeliveredMessagesHandler({}), + fetchSubscriptionLastUndeliveredMessageHandler({}), +]; + export const fetchConstraintsHandler = ({ constraints, }: { @@ -419,6 +585,132 @@ export const fetchGroupInconsistenciesErrorHandler = ({ return res(ctx.status(errorCode), ctx.json(undefined)); }); +export const removeGroupHandler = ({ group }: { group: string }) => + rest.delete(`/groups/${group}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(undefined)); + }); + +export const removeGroupErrorHandler = ({ + group, + errorCode = 500, +}: { + group: string; + errorCode: number; +}) => + rest.delete(`/groups/${group}`, (req, res, ctx) => { + return res(ctx.status(errorCode), ctx.json(undefined)); + }); + +export const removeTopicHandler = ({ topic }: { topic: string }) => + rest.delete(`/topics/${topic}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(undefined)); + }); + +export const removeTopicErrorHandler = ({ + topic, + errorCode = 500, +}: { + topic: string; + errorCode: number; +}) => + rest.delete(`/topics/${topic}`, (req, res, ctx) => { + return res(ctx.status(errorCode), ctx.json(undefined)); + }); + +export const removeInconsistentTopicHandler = () => + rest.delete(`/consistency/inconsistencies/topics`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(undefined)); + }); + +export const removeInconsistentTopicErrorHandler = ({ + errorCode = 500, +}: { + errorCode: number; +}) => + rest.delete(`/consistency/inconsistencies/topics`, (req, res, ctx) => { + return res(ctx.status(errorCode), ctx.json(undefined)); + }); + +export const removeSubscriptionHandler = ({ + topic, + subscription, +}: { + topic: string; + subscription: string; +}) => + rest.delete( + `/topics/${topic}/subscriptions/${subscription}`, + (req, res, ctx) => { + return res(ctx.status(200), ctx.json(undefined)); + }, + ); + +export const removeSubscriptionErrorHandler = ({ + topic, + subscription, + errorCode = 500, +}: { + topic: string; + subscription: string; + errorCode: number; +}) => + rest.delete( + `/topics/${topic}/subscriptions/${subscription}`, + (req, res, ctx) => { + return res(ctx.status(errorCode), ctx.json(undefined)); + }, + ); + +export const subscriptionStateHandler = ({ + topic, + subscription, +}: { + topic: string; + subscription: string; +}) => + rest.put( + `/topics/${topic}/subscriptions/${subscription}/state`, + (req, res, ctx) => { + return res(ctx.status(200), ctx.json(undefined)); + }, + ); + +export const subscriptionStateErrorHandler = ({ + topic, + subscription, + errorCode = 500, +}: { + topic: string; + subscription: string; + errorCode: number; +}) => + rest.put( + `/topics/${topic}/subscriptions/${subscription}/state`, + (req, res, ctx) => { + return res(ctx.status(errorCode), ctx.json(undefined)); + }, + ); + +export const switchReadinessHandler = ({ + datacenter, +}: { + datacenter: string; +}) => + rest.post(`/readiness/datacenters/${datacenter}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(undefined)); + }); + +export const switchReadinessErrorHandler = ({ + datacenter, + errorCode, +}: { + datacenter: string; + errorCode: number; +}) => + rest.post(`/readiness/datacenters/${datacenter}`, (req, res, ctx) => { + return res(ctx.status(errorCode), ctx.json(undefined)); + }); + export const moveSubscriptionOffsetsHandler = ({ topicName, subscriptionName, diff --git a/hermes-console-vue/src/store/app-notifications/useAppNotifications.ts b/hermes-console-vue/src/store/app-notifications/useAppNotifications.ts index 0457818805..6599cb47a8 100644 --- a/hermes-console-vue/src/store/app-notifications/useAppNotifications.ts +++ b/hermes-console-vue/src/store/app-notifications/useAppNotifications.ts @@ -28,4 +28,5 @@ export const useNotificationsStore = defineStore('notifications', { ); }, }, + persist: true, }); diff --git a/hermes-console-vue/src/store/auth/useAuthStore.spec.ts b/hermes-console-vue/src/store/auth/useAuthStore.spec.ts index 0844b0bcf2..28d599d53e 100644 --- a/hermes-console-vue/src/store/auth/useAuthStore.spec.ts +++ b/hermes-console-vue/src/store/auth/useAuthStore.spec.ts @@ -1,11 +1,11 @@ import { createPinia, setActivePinia } from 'pinia'; import { dummyAppConfig } from '@/dummy/app-config'; +import { dummyValidToken } from '@/dummy/jwt-tokens'; import { expect } from 'vitest'; import { fetchTokenHandler } from '@/mocks/handlers'; import { setupServer } from 'msw/node'; import { useAppConfigStore } from '@/store/app-config/useAppConfigStore'; import { useAuthStore } from '@/store/auth/useAuthStore'; -import { validToken } from '@/utils/jwt-utils'; describe('useGroups', () => { const server = setupServer(); @@ -47,7 +47,7 @@ describe('useGroups', () => { it('should exchange code for token', async () => { //given server.use( - fetchTokenHandler({ accessToken: { access_token: validToken } }), + fetchTokenHandler({ accessToken: { access_token: dummyValidToken } }), ); server.listen(); const configStore = useAppConfigStore(); @@ -58,7 +58,7 @@ describe('useGroups', () => { await authStore.exchangeCodeForTokenWithPKCE('codeXYZ'); // then - expect(authStore.accessToken).toBe(validToken); + expect(authStore.accessToken).toBe(dummyValidToken); expect(authStore.codeVerifier).toBeNull(); expect(authStore.isUserAuthorized).toBeTruthy(); }); diff --git a/hermes-console-vue/src/store/auth/useAuthStore.ts b/hermes-console-vue/src/store/auth/useAuthStore.ts index 43fa7da20f..eed5c9af22 100644 --- a/hermes-console-vue/src/store/auth/useAuthStore.ts +++ b/hermes-console-vue/src/store/auth/useAuthStore.ts @@ -2,7 +2,7 @@ import { encode as base64encode } from 'base64-arraybuffer'; import { defineStore } from 'pinia'; import { fetchToken } from '@/api/hermes-client'; import { useAppConfigStore } from '@/store/app-config/useAppConfigStore'; -import axios from 'axios'; +import axios from '@/utils/axios/axios-instance'; import decode from 'jwt-decode'; import qs from 'query-string'; import type { AuthStoreState } from '@/store/auth/types'; diff --git a/hermes-console-vue/src/utils/axios/axios-instance.ts b/hermes-console-vue/src/utils/axios/axios-instance.ts new file mode 100644 index 0000000000..c7598568b0 --- /dev/null +++ b/hermes-console-vue/src/utils/axios/axios-instance.ts @@ -0,0 +1,10 @@ +import axios from 'axios'; + +const instance = axios.create({ + baseURL: import.meta.env.DEV + ? 'http://localhost:3000' + : window.location.origin, + timeout: 1000 * 30, //30s +}); + +export default instance; diff --git a/hermes-console-vue/src/utils/axios-utils.ts b/hermes-console-vue/src/utils/axios/axios-utils.ts similarity index 100% rename from hermes-console-vue/src/utils/axios-utils.ts rename to hermes-console-vue/src/utils/axios/axios-utils.ts diff --git a/hermes-console-vue/src/views/admin/consistency/ConsistencyView.spec.ts b/hermes-console-vue/src/views/admin/consistency/ConsistencyView.spec.ts index c65a1b478e..08b4a208f2 100644 --- a/hermes-console-vue/src/views/admin/consistency/ConsistencyView.spec.ts +++ b/hermes-console-vue/src/views/admin/consistency/ConsistencyView.spec.ts @@ -1,6 +1,11 @@ -import { consistencyStoreState } from '@/dummy/store'; +import { + consistencyStoreState, + createTestingPiniaWithState, +} from '@/dummy/store'; import { createTestingPinia } from '@pinia/testing'; import { dummyInconsistentTopics } from '@/dummy/inconsistentTopics'; +import { expect } from 'vitest'; +import { fireEvent } from '@testing-library/vue'; import { ref } from 'vue'; import { render } from '@/utils/test-utils'; import { useInconsistentTopics } from '@/composables/inconsistent-topics/use-inconsistent-topics/useInconsistentTopics'; @@ -17,6 +22,7 @@ const useInconsistentTopicsStub: UseInconsistentTopics = { fetchInconsistentTopics: null, }), loading: ref(false), + removeInconsistentTopic: () => Promise.resolve(true), }; describe('ConsistencyView', () => { @@ -196,4 +202,31 @@ describe('ConsistencyView', () => { queryByText('consistency.connectionError.text'), ).not.toBeInTheDocument(); }); + + it('should show confirmation dialog on remove button click', async () => { + // given + vi.mocked(useInconsistentTopics).mockReturnValueOnce( + useInconsistentTopicsStub, + ); + + // when + const { getAllByText, getByText } = render(ConsistencyView, { + testPinia: createTestingPiniaWithState(), + }); + await fireEvent.click( + getAllByText('consistency.inconsistentTopics.actions.delete')[0], + ); + + // then + expect( + getByText( + 'consistency.inconsistentTopics.confirmationDialog.remove.title', + ), + ).toBeInTheDocument(); + expect( + getByText( + 'consistency.inconsistentTopics.confirmationDialog.remove.text', + ), + ).toBeInTheDocument(); + }); }); diff --git a/hermes-console-vue/src/views/admin/consistency/ConsistencyView.vue b/hermes-console-vue/src/views/admin/consistency/ConsistencyView.vue index ab9c927559..360ddcdaf8 100644 --- a/hermes-console-vue/src/views/admin/consistency/ConsistencyView.vue +++ b/hermes-console-vue/src/views/admin/consistency/ConsistencyView.vue @@ -1,8 +1,11 @@