Skip to content

Commit 2b6db50

Browse files
committed
test(ui): add Invitations components tests
1 parent 88faec0 commit 2b6db50

File tree

9 files changed

+638
-4
lines changed

9 files changed

+638
-4
lines changed

ui/tests/components/AppBar/__snapshots__/AppBar.spec.ts.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ exports[`AppBar Component > Renders the component 1`] = `
4444
<!--teleport start-->
4545
<!--teleport end-->
4646
<!--v-if-->
47-
<div class="v-badge" size="x-small" data-test="invitations-dropdown-badge">
48-
<div class="v-badge__wrapper"><i class="mdi-email mdi v-icon notranslate v-theme--light v-icon--size-default text-primary v-icon--clickable" role="button" aria-hidden="false" tabindex="0" aria-label="Open pending invites menu" data-test="invites-icon"></i>
47+
<div class="v-badge" size="x-small" data-test="invitations-menu-badge">
48+
<div class="v-badge__wrapper"><i class="mdi-email mdi v-icon notranslate v-theme--light v-icon--size-default text-primary v-icon--clickable" role="button" aria-hidden="false" tabindex="0" aria-label="Open pending invitations menu" data-test="invitations-menu-icon"></i>
4949
<transition-stub name="scale-rotate-transition" appear="false" persisted="false" css="true"><span class="v-badge__badge v-theme--light bg-success" style="bottom: calc(100% - 8px); left: calc(100% - 8px); display: none;" aria-atomic="true" aria-label="Badge" aria-live="polite" role="status">0</span></transition-stub>
5050
</div>
5151
</div>
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { setActivePinia, createPinia } from "pinia";
2+
import { createVuetify } from "vuetify";
3+
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
4+
import { describe, expect, it, vi } from "vitest";
5+
import { VLayout } from "vuetify/components";
6+
import InvitationsMenu from "@/components/Invitations/InvitationsMenu.vue";
7+
import { SnackbarInjectionKey } from "@/plugins/snackbar";
8+
import useInvitationsStore from "@/store/modules/invitations";
9+
import { IInvitation } from "@/interfaces/IInvitation";
10+
11+
const Component = {
12+
template: "<v-layout><InvitationsMenu v-model=\"show\" /></v-layout>",
13+
props: ["modelValue"],
14+
data: () => ({
15+
show: true,
16+
}),
17+
};
18+
19+
const mockSnackbar = {
20+
showSuccess: vi.fn(),
21+
showError: vi.fn(),
22+
};
23+
24+
const mockInvitations: IInvitation[] = [
25+
{
26+
status: "pending",
27+
role: "operator",
28+
invited_by: "admin",
29+
expires_at: "2025-12-31T23:59:59Z",
30+
created_at: "2025-12-01T00:00:00Z",
31+
updated_at: "2025-12-01T00:00:00Z",
32+
status_updated_at: "2025-12-01T00:00:00Z",
33+
namespace: {
34+
tenant_id: "tenant1",
35+
name: "Namespace 1",
36+
},
37+
user: {
38+
id: "user1",
39+
email: "user@example.com",
40+
},
41+
},
42+
];
43+
44+
const vuetify = createVuetify();
45+
46+
const mountWrapper = () => mount(Component, {
47+
global: {
48+
plugins: [vuetify],
49+
provide: { [SnackbarInjectionKey]: mockSnackbar },
50+
components: { "v-layout": VLayout, InvitationsMenu },
51+
stubs: { teleport: true },
52+
},
53+
props: { modelValue: true },
54+
attachTo: document.body,
55+
});
56+
57+
describe("InvitationsMenu", () => {
58+
let wrapper: VueWrapper<unknown>;
59+
let menu: VueWrapper<InstanceType<typeof InvitationsMenu>>;
60+
61+
setActivePinia(createPinia());
62+
const invitationsStore = useInvitationsStore();
63+
invitationsStore.fetchUserPendingInvitationList = vi.fn().mockResolvedValue(Promise.resolve(mockInvitations));
64+
65+
it("Opens drawer when icon is clicked", async () => {
66+
wrapper = mountWrapper();
67+
await wrapper.find('[data-test="invitations-menu-icon"]').trigger("click");
68+
menu = wrapper.findComponent(InvitationsMenu);
69+
menu.vm.isDrawerOpen = false;
70+
const icon = wrapper.find('[data-test="invitations-menu-icon"]');
71+
await icon.trigger("click");
72+
await flushPromises();
73+
74+
expect(menu.vm.isDrawerOpen).toBe(true);
75+
const drawerComponent = wrapper.find('[data-test="invitations-drawer"]');
76+
expect(drawerComponent.exists()).toBe(true);
77+
});
78+
79+
it("Fetches invitations on mount", async () => {
80+
const storeSpy = vi.spyOn(invitationsStore, "fetchUserPendingInvitationList");
81+
wrapper.unmount();
82+
wrapper = mountWrapper();
83+
await flushPromises();
84+
expect(storeSpy).toHaveBeenCalled();
85+
});
86+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { setActivePinia, createPinia } from "pinia";
2+
import { createVuetify } from "vuetify";
3+
import { mount, VueWrapper } from "@vue/test-utils";
4+
import { beforeEach, describe, expect, it } from "vitest";
5+
import InvitationsMenuItem from "@/components/Invitations/InvitationsMenuItem.vue";
6+
import { IInvitation } from "@/interfaces/IInvitation";
7+
import { formatFullDateTime } from "@/utils/date";
8+
import { SnackbarPlugin } from "@/plugins/snackbar";
9+
10+
type InvitationsMenuItemWrapper = VueWrapper<InstanceType<typeof InvitationsMenuItem>>;
11+
12+
const mockInvitation: IInvitation = {
13+
status: "pending",
14+
role: "operator",
15+
invited_by: "638af3e2c3a5f90008c8b456",
16+
expires_at: "2025-12-31T23:59:59Z",
17+
created_at: "2025-12-01T00:00:00Z",
18+
updated_at: "2025-12-01T00:00:00Z",
19+
status_updated_at: "2025-12-01T00:00:00Z",
20+
namespace: {
21+
tenant_id: "tenant1",
22+
name: "Test Namespace",
23+
},
24+
user: {
25+
id: "user1",
26+
email: "user@example.com",
27+
},
28+
};
29+
30+
describe("InvitationsMenuItem", () => {
31+
let wrapper: InvitationsMenuItemWrapper;
32+
setActivePinia(createPinia());
33+
const vuetify = createVuetify();
34+
35+
beforeEach(() => {
36+
wrapper = mount(InvitationsMenuItem, {
37+
global: {
38+
plugins: [vuetify, SnackbarPlugin],
39+
},
40+
props: {
41+
invitation: mockInvitation,
42+
},
43+
});
44+
});
45+
46+
it("Displays namespace name", () => {
47+
expect(wrapper.text()).toContain("Test Namespace");
48+
});
49+
50+
it("Displays role", () => {
51+
expect(wrapper.text()).toContain(mockInvitation.role);
52+
});
53+
54+
it("Displays invitation description", () => {
55+
const formattedCreatedAt = formatFullDateTime(mockInvitation.created_at);
56+
expect(wrapper.text()).toContain(`Invited by ${mockInvitation.invited_by} at ${formattedCreatedAt}`);
57+
});
58+
59+
it("Emits update event when invitation is accepted/declined successfully", () => {
60+
wrapper.vm.handleSuccess();
61+
expect(wrapper.emitted("update")).toBeTruthy();
62+
});
63+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { setActivePinia, createPinia } from "pinia";
2+
import { createVuetify } from "vuetify";
3+
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import MockAdapter from "axios-mock-adapter";
6+
import InvitationCancel from "@/components/Team/Invitation/InvitationCancel.vue";
7+
import { namespacesApi } from "@/api/http";
8+
import { SnackbarInjectionKey } from "@/plugins/snackbar";
9+
import useInvitationsStore from "@/store/modules/invitations";
10+
import { IInvitation } from "@/interfaces/IInvitation";
11+
12+
type InvitationCancelWrapper = VueWrapper<InstanceType<typeof InvitationCancel>>;
13+
14+
const mockSnackbar = {
15+
showSuccess: vi.fn(),
16+
showError: vi.fn(),
17+
};
18+
19+
const invitation: IInvitation = {
20+
status: "pending",
21+
role: "operator",
22+
invited_by: "user1",
23+
expires_at: "2025-12-31T23:59:59Z",
24+
created_at: "2025-12-01T00:00:00Z",
25+
updated_at: "2025-12-01T00:00:00Z",
26+
status_updated_at: "2025-12-01T00:00:00Z",
27+
namespace: {
28+
tenant_id: "fake-tenant",
29+
name: "Test Namespace",
30+
},
31+
user: {
32+
id: "user123",
33+
email: "test@example.com",
34+
},
35+
};
36+
37+
describe("InvitationCancel", () => {
38+
let wrapper: InvitationCancelWrapper;
39+
setActivePinia(createPinia());
40+
const invitationsStore = useInvitationsStore();
41+
const vuetify = createVuetify();
42+
const mockNamespacesApi = new MockAdapter(namespacesApi.getAxios());
43+
44+
beforeEach(() => {
45+
wrapper = mount(InvitationCancel, {
46+
global: {
47+
plugins: [vuetify],
48+
provide: { [SnackbarInjectionKey]: mockSnackbar },
49+
},
50+
props: {
51+
invitation,
52+
hasAuthorization: true,
53+
},
54+
});
55+
});
56+
57+
it("Cancel invitation success", async () => {
58+
mockNamespacesApi.onDelete("http://localhost:3000/api/namespaces/fake-tenant/invitations/user123").reply(200);
59+
60+
const storeSpy = vi.spyOn(invitationsStore, "cancelInvitation");
61+
62+
await wrapper.findComponent('[data-test="invitation-cancel-btn"]').trigger("click");
63+
await wrapper.findComponent('[data-test="cancel-invitation-btn"]').trigger("click");
64+
await flushPromises();
65+
66+
expect(storeSpy).toBeCalledWith({
67+
tenant: "fake-tenant",
68+
user_id: "user123",
69+
});
70+
71+
expect(mockSnackbar.showSuccess).toBeCalledWith("Successfully cancelled invitation.");
72+
});
73+
74+
it("Cancel invitation error", async () => {
75+
mockNamespacesApi.onDelete("http://localhost:3000/api/namespaces/fake-tenant/invitations/user123").reply(404);
76+
77+
const storeSpy = vi.spyOn(invitationsStore, "cancelInvitation");
78+
79+
await wrapper.findComponent('[data-test="invitation-cancel-btn"]').trigger("click");
80+
await wrapper.findComponent('[data-test="cancel-invitation-btn"]').trigger("click");
81+
await flushPromises();
82+
83+
expect(storeSpy).toBeCalledWith({
84+
tenant: "fake-tenant",
85+
user_id: "user123",
86+
});
87+
88+
expect(mockSnackbar.showError).toBeCalledWith("Invitation not found.");
89+
});
90+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { setActivePinia, createPinia } from "pinia";
2+
import { createVuetify } from "vuetify";
3+
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import MockAdapter from "axios-mock-adapter";
6+
import InvitationEdit from "@/components/Team/Invitation/InvitationEdit.vue";
7+
import { namespacesApi } from "@/api/http";
8+
import { SnackbarInjectionKey } from "@/plugins/snackbar";
9+
import useInvitationsStore from "@/store/modules/invitations";
10+
import { IInvitation } from "@/interfaces/IInvitation";
11+
12+
type InvitationEditWrapper = VueWrapper<InstanceType<typeof InvitationEdit>>;
13+
14+
const mockSnackbar = {
15+
showSuccess: vi.fn(),
16+
showError: vi.fn(),
17+
};
18+
19+
const invitation: IInvitation = {
20+
status: "pending",
21+
role: "operator",
22+
invited_by: "user1",
23+
expires_at: "2025-12-31T23:59:59Z",
24+
created_at: "2025-12-01T00:00:00Z",
25+
updated_at: "2025-12-01T00:00:00Z",
26+
status_updated_at: "2025-12-01T00:00:00Z",
27+
namespace: {
28+
tenant_id: "fake-tenant",
29+
name: "Test Namespace",
30+
},
31+
user: {
32+
id: "user123",
33+
email: "test@example.com",
34+
},
35+
};
36+
37+
describe("InvitationEdit", () => {
38+
let wrapper: InvitationEditWrapper;
39+
setActivePinia(createPinia());
40+
const invitationsStore = useInvitationsStore();
41+
const vuetify = createVuetify();
42+
const mockNamespacesApi = new MockAdapter(namespacesApi.getAxios());
43+
44+
beforeEach(() => {
45+
wrapper = mount(InvitationEdit, {
46+
global: {
47+
plugins: [vuetify],
48+
provide: { [SnackbarInjectionKey]: mockSnackbar },
49+
},
50+
props: {
51+
invitation,
52+
hasAuthorization: true,
53+
},
54+
});
55+
});
56+
57+
it("Successfully edits invitation", async () => {
58+
mockNamespacesApi.onPatch("http://localhost:3000/api/namespaces/fake-tenant/invitations/user123").reply(200);
59+
60+
const storeSpy = vi.spyOn(invitationsStore, "editInvitation");
61+
62+
await wrapper.findComponent('[data-test="invitation-edit-btn"]').trigger("click");
63+
await wrapper.findComponent('[data-test="update-btn"]').trigger("click");
64+
await flushPromises();
65+
66+
expect(storeSpy).toBeCalledWith({
67+
tenant: "fake-tenant",
68+
user_id: "user123",
69+
role: "operator",
70+
});
71+
72+
expect(mockSnackbar.showSuccess).toBeCalledWith("Successfully updated invitation role.");
73+
});
74+
75+
it("Fails to edit invitation due to permission error", async () => {
76+
mockNamespacesApi.onPatch("http://localhost:3000/api/namespaces/fake-tenant/invitations/user123").reply(403);
77+
78+
const storeSpy = vi.spyOn(invitationsStore, "editInvitation");
79+
80+
await wrapper.findComponent('[data-test="invitation-edit-btn"]').trigger("click");
81+
await wrapper.findComponent('[data-test="update-btn"]').trigger("click");
82+
await flushPromises();
83+
84+
expect(storeSpy).toBeCalledWith({
85+
tenant: "fake-tenant",
86+
user_id: "user123",
87+
role: "operator",
88+
});
89+
90+
expect(mockSnackbar.showError).toBeCalledWith("You don't have permission to edit invitations.");
91+
});
92+
});

0 commit comments

Comments
 (0)