[Closes #8] Added authentication
Some checks failed
Production Build and Deploy / Build (push) Failing after 44s
Production Build and Deploy / Deploy (push) Has been skipped

This commit is contained in:
Liviu Burcusel 2026-01-07 11:11:35 +01:00
parent 6eefa137bb
commit 97211cdccd
Signed by: liviu
GPG key ID: 6CDB37A4AD2C610C
65 changed files with 5831 additions and 440 deletions

View file

@ -1,38 +1,89 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi, beforeAll, afterAll } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import DefaultLayout from "~/layouts/Default.vue";
vi.mock("#app", () => ({
useRuntimeConfig: () => ({
public: {
appVersion: "1.0.1",
},
}),
}));
vi.mock("~/stores/auth", () => ({
useAuthStore: () => ({
init: vi.fn(),
user: null,
}),
}));
describe("Default.vue", () => {
beforeAll(() => {
const shouldSuppress = (args: string[]) => {
const msg = args.join(" ");
return msg.includes("<Suspense> is an experimental feature");
};
const spyMethods = ["warn", "error", "log", "info"] as const;
for (const method of spyMethods) {
const original = console[method];
vi.spyOn(console, method).mockImplementation((...args) => {
if (shouldSuppress(args)) return;
original(...args);
});
}
});
afterAll(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("loads without crashing", () => {
const wrapper = mount(DefaultLayout);
it("loads without crashing", async () => {
const wrapper = mount({
components: { DefaultLayout },
template: "<Suspense><DefaultLayout /></Suspense>",
});
await flushPromises(); // Wait for async setup
expect(wrapper.exists()).toBe(true);
});
describe("Footer", () => {
it("footer is displayed", () => {
const wrapper = mount(DefaultLayout);
it("footer is displayed", async () => {
const wrapper = mount({
components: { DefaultLayout },
template: "<Suspense><DefaultLayout /></Suspense>",
});
await flushPromises();
const footer = wrapper.find("[data-testid='footer']");
expect(footer.exists()).toBe(true);
});
it("footer shows only 2025 when current year is 2025", () => {
it("footer shows only 2025 when current year is 2025", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2025, 0, 1));
const wrapper = mount(DefaultLayout);
const wrapper = mount({
components: { DefaultLayout },
template: "<Suspense><DefaultLayout /></Suspense>",
});
await flushPromises();
const footer = wrapper.find("[data-testid='footer']");
expect(footer.text()).toBe("Glowing Fiesta 2025");
expect(footer.text()).toBe("Glowing Fiesta 2025 (1.0.1)");
});
it("footer shows range when current year is not 2025", () => {
it("footer shows range when current year is not 2025", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2069, 0, 1));
const wrapper = mount(DefaultLayout);
const wrapper = mount({
components: { DefaultLayout },
template: "<Suspense><DefaultLayout /></Suspense>",
});
await flushPromises();
const footer = wrapper.find("[data-testid='footer']");
expect(footer.text()).toBe("Glowing Fiesta 2025 - 2069");
expect(footer.text()).toBe("Glowing Fiesta 2025 - 2069 (1.0.1)");
});
});
});

View file

@ -1,5 +1,5 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { describe, expect, it, vi, beforeAll, afterAll } from "vitest";
import SidebarLayout from "~/layouts/default/Sidebar.vue";
import { ref } from "vue";
import type * as SidebarUI from "~/components/ui/sidebar";
@ -8,6 +8,20 @@ const { useSidebarMock } = vi.hoisted(() => ({
useSidebarMock: vi.fn(),
}));
// ... (existing mocks)
vi.mock("~/stores/auth", () => ({
useAuthStore: () => ({
user: {
name: "Liviu",
email: "x.liviu@gmail.com",
image: "avatar.png",
},
signOut: vi.fn(),
init: vi.fn(),
}),
}));
// Mock the UI components and hook
vi.mock("~/components/ui/sidebar", async (importOriginal) => {
const actual = await importOriginal<typeof SidebarUI>();
@ -99,6 +113,7 @@ vi.mock("lucide-vue-next", () => {
CreditCard: MockIcon,
BookOpen: MockIcon,
HandCoins: MockIcon,
LogIn: MockIcon,
LogOut: MockIcon,
Settings2: MockIcon,
Sparkles: MockIcon,
@ -107,7 +122,26 @@ vi.mock("lucide-vue-next", () => {
});
describe("SidebarLayout", () => {
it("renders the header correctly", () => {
beforeAll(() => {
const shouldSuppress = (args: string[]) => {
const msg = args.join(" ");
return msg.includes("<Suspense> is an experimental feature");
};
const spyMethods = ["warn", "error", "log", "info"] as const;
for (const method of spyMethods) {
const original = console[method];
vi.spyOn(console, method).mockImplementation((...args) => {
if (shouldSuppress(args)) return;
original(...args);
});
}
});
afterAll(() => {
vi.restoreAllMocks();
});
it("renders the header correctly", async () => {
useSidebarMock.mockReturnValue({
isMobile: ref(false),
state: ref("expanded"),
@ -115,17 +149,41 @@ describe("SidebarLayout", () => {
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarLayout, {
props: {
collapsible: "icon",
const wrapper = mount({
components: { SidebarLayout },
template: "<Suspense><SidebarLayout v-bind='props' /></Suspense>",
setup() {
return {
props: {
collapsible: "icon",
},
};
},
});
await flushPromises();
expect(wrapper.text()).toContain("Glowing Fiesta");
expect(wrapper.text()).toContain("v1.0.0");
});
it("renders sidebar content correctly", () => {
const wrapper = mount(SidebarLayout);
it("renders sidebar content correctly", async () => {
const user = {
name: "Liviu",
email: "x.liviu@gmail.com",
image: "avatar.png",
id: "123",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const wrapper = mount({
components: { SidebarLayout },
template: "<Suspense><SidebarLayout :user='user' /></Suspense>",
setup() {
return { user };
},
});
await flushPromises();
const text = wrapper.text();
// Navigation groups
@ -144,24 +202,31 @@ describe("SidebarLayout", () => {
expect(text).toContain("Get Started");
});
it("does not render icon if item.icon is missing", () => {
const wrapper = mount(SidebarLayout, {
props: {
navItems: [
{
title: "No Icon Item",
url: "#",
items: [],
it("does not render icon if item.icon is missing", async () => {
const wrapper = mount({
components: { SidebarLayout },
template: "<Suspense><SidebarLayout v-bind='props' /></Suspense>",
setup() {
return {
props: {
navItems: [
{
title: "No Icon Item",
url: "#",
items: [],
},
],
},
],
};
},
});
await flushPromises();
expect(wrapper.text()).toContain("No Icon Item");
expect(wrapper.find('[data-testid="sidebar-icon"]').exists()).toBe(false);
});
it("renders correctly in mobile view", () => {
it("renders correctly in mobile view", async () => {
useSidebarMock.mockReturnValue({
isMobile: ref(true),
state: ref("expanded"),
@ -169,7 +234,24 @@ describe("SidebarLayout", () => {
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarLayout);
const user = {
name: "Liviu",
email: "x.liviu@gmail.com",
image: "avatar.png",
id: "123",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const wrapper = mount({
components: { SidebarLayout },
template: "<Suspense><SidebarLayout :user='user' /></Suspense>",
setup() {
return { user };
},
});
await flushPromises();
// When isMobile is true, it renders a Sheet.
// Since we mocked Sheet components as simple divs with slots, the content should still be present.

View file

@ -0,0 +1,336 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import SidebarFooterComponent from "~/layouts/default/SidebarFooter.vue";
import type * as SidebarUI from "~/components/ui/sidebar";
const { useSidebarMock } = vi.hoisted(() => ({
useSidebarMock: vi.fn(),
}));
// Mock navigateTo
const navigateToMock = vi.fn();
vi.stubGlobal("navigateTo", navigateToMock);
// Mock UI components
vi.mock("~/components/ui/sidebar", async (importOriginal) => {
const actual = await importOriginal<typeof SidebarUI>();
const { ref } = await import("vue");
const MockComponent = {
template: "<div><slot /></div>",
inheritAttrs: false,
};
useSidebarMock.mockReturnValue({
isMobile: ref(false), // Ensure isMobile is a ref
state: ref("expanded"),
openMobile: ref(false),
setOpenMobile: vi.fn(),
});
return {
...actual,
SidebarFooter: MockComponent,
SidebarMenu: MockComponent,
SidebarMenuItem: MockComponent,
SidebarMenuButton: MockComponent,
useSidebar: useSidebarMock,
};
});
vi.mock("~/components/ui/avatar", () => {
const MockComponent = { template: "<div><slot /></div>" };
return {
Avatar: MockComponent,
AvatarFallback: MockComponent,
AvatarImage: MockComponent,
};
});
vi.mock("~/components/ui/dropdown-menu", () => {
const MockComponent = { template: "<div><slot /></div>" };
const MockContent = {
template: '<div data-testid="dropdown-content"><slot /></div>',
name: "DropdownMenuContent",
};
const DropdownMenuItem = {
name: "DropdownMenuItem",
template: '<div data-testid="dropdown-item" @click="$emit(\'click\')"><slot /></div>',
emits: ["click"],
};
return {
DropdownMenu: MockComponent,
DropdownMenuTrigger: MockComponent,
DropdownMenuContent: MockContent,
DropdownMenuGroup: MockComponent,
DropdownMenuItem: DropdownMenuItem,
DropdownMenuLabel: MockComponent,
DropdownMenuSeparator: MockComponent,
};
});
vi.mock("lucide-vue-next", () => {
const MockIcon = { template: '<svg class="lucide-mock" />' };
return {
BadgeCheck: MockIcon,
Bell: MockIcon,
ChevronsUpDown: MockIcon,
CreditCard: MockIcon,
LogIn: MockIcon,
LogOut: MockIcon,
};
});
describe("SidebarFooter.vue", () => {
const user = {
name: "Liviu Test",
email: "test@example.com",
image: "avatar.png",
id: "123",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
};
it("renders user information correctly when user is provided", () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
expect(wrapper.text()).toContain("Liviu Test");
expect(wrapper.text()).toContain("test@example.com");
// Initials "Liviu Test" -> "LT"
expect(wrapper.text()).toContain("LT");
});
it("renders anonymous view correctly when user is not provided (null)", () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user: null },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
expect(wrapper.text()).toContain("Anonymous");
expect(wrapper.text()).toContain("No email");
expect(wrapper.text()).toContain("Anon");
});
it("renders anonymous view correctly when user is undefined", () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user: undefined },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
expect(wrapper.text()).toContain("Anonymous");
expect(wrapper.text()).toContain("No email");
expect(wrapper.text()).toContain("Anon");
});
it("renders 'Log out' option when user is logged in", () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
expect(wrapper.text()).toContain("Log out");
expect(wrapper.text()).not.toContain("Log in");
});
it("renders 'Log in' option when user is anonymous", () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user: null },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
expect(wrapper.text()).toContain("Log in");
expect(wrapper.text()).not.toContain("Log out");
});
it("calls navigateTo('/member/auth/logout') when Log out is clicked", async () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
const logoutItem = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().includes("Log out"));
expect(logoutItem).toBeDefined();
await logoutItem?.trigger("click");
expect(navigateToMock).toHaveBeenCalledWith("/member/auth/logout");
});
it("calls navigateTo('/member/auth/login') when Log in is clicked", async () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user: null },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
const loginItem = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().includes("Log in"));
expect(loginItem).toBeDefined();
await loginItem?.trigger("click");
expect(navigateToMock).toHaveBeenCalledWith("/member/auth/login");
});
it("computes initials correctly for single name", () => {
const singleNameUser = { ...user, name: "Liviu" };
const wrapper = mount(SidebarFooterComponent, {
props: { user: singleNameUser },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
// "Liviu" -> "L"
expect(wrapper.text()).toContain("L");
});
it("renders correctly when user has no image", () => {
const userNoImage = { ...user, image: null as unknown as string };
const wrapper = mount(SidebarFooterComponent, {
props: { user: userNoImage },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
expect(wrapper.text()).toContain("Liviu Test");
expect(wrapper.text()).toContain("test@example.com");
expect(wrapper.text()).toContain("LT");
});
it("sets side to 'bottom' when isMobile is true", () => {
const { ref } = require("vue");
useSidebarMock.mockReturnValue({
isMobile: ref(true),
state: ref("expanded"),
openMobile: ref(false),
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarFooterComponent, {
props: { user },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
const dropdownContent = wrapper.findComponent({ name: "DropdownMenuContent" });
expect(dropdownContent.attributes("side")).toBe("bottom");
});
it("sets side to 'right' when isMobile is false", () => {
const { ref } = require("vue");
useSidebarMock.mockReturnValue({
isMobile: ref(false),
state: ref("expanded"),
openMobile: ref(false),
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarFooterComponent, {
props: { user },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
const dropdownContent = wrapper.findComponent({ name: "DropdownMenuContent" });
expect(dropdownContent.attributes("side")).toBe("right");
});
it("sets side to 'bottom' when isMobile is true and user is anonymous", () => {
const { ref } = require("vue");
useSidebarMock.mockReturnValue({
isMobile: ref(true),
state: ref("expanded"),
openMobile: ref(false),
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarFooterComponent, {
props: { user: null },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
const dropdownContent = wrapper.findComponent({ name: "DropdownMenuContent" });
expect(dropdownContent.attributes("side")).toBe("bottom");
});
it("sets side to 'right' when isMobile is false and user is anonymous", () => {
const { ref } = require("vue");
useSidebarMock.mockReturnValue({
isMobile: ref(false),
state: ref("expanded"),
openMobile: ref(false),
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarFooterComponent, {
props: { user: null },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
const dropdownContent = wrapper.findComponent({ name: "DropdownMenuContent" });
expect(dropdownContent.attributes("side")).toBe("right");
});
it("returns empty string for userInititials when user is undefined (covering line 24)", () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user: undefined },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
// Access the component's internal state/computed properties
expect((wrapper.vm as any).userInititials).toBe("");
});
});

View file

@ -0,0 +1,88 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import CreateAccountPage from "~/pages/member/auth/create-account.vue";
// Mock auth client
const authMocks = vi.hoisted(() => ({
signUpEmail: vi.fn(),
}));
vi.mock("~~/shared/utils/auth-client", () => ({
authClient: {
signUp: {
email: authMocks.signUpEmail,
},
},
}));
// Mock UI components
vi.mock("@/components/ui/button", () => ({
Button: { template: "<button><slot /></button>" },
}));
vi.mock("@/components/ui/input", () => ({
Input: {
props: ["modelValue", "id", "type"],
emits: ["update:modelValue"],
template: `<input :id="id" :type="type" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`,
},
}));
vi.mock("@/components/ui/card", () => ({
Card: { template: "<div><slot /></div>" },
CardHeader: { template: "<div><slot /></div>" },
CardTitle: { template: "<h1><slot /></h1>" },
CardDescription: { template: "<p><slot /></p>" },
CardContent: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/ui/field", () => ({
Field: { template: "<div><slot /></div>" },
FieldGroup: { template: "<div><slot /></div>" },
FieldLabel: { template: "<label><slot /></label>" },
FieldDescription: { template: "<span><slot /></span>" },
}));
describe("CreateAccountPage", () => {
beforeEach(() => {
vi.clearAllMocks();
authMocks.signUpEmail.mockResolvedValue({ data: {}, error: null });
});
it("renders the signup form correctly", async () => {
const wrapper = mount(CreateAccountPage);
// Wait for any async rendering if necessary
await flushPromises();
expect(wrapper.text()).toContain("Create your account");
expect(wrapper.find('input[type="email"]').exists()).toBe(true);
expect(wrapper.find('input[type="password"]').exists()).toBe(true);
});
it("submits the form with correct data", async () => {
const wrapper = mount(CreateAccountPage);
await flushPromises();
// Fill the form
const nameInput = wrapper.find('input[id="name"]');
const emailInput = wrapper.find('input[id="email"]');
const passwordInput = wrapper.find('input[id="password"]');
const confirmPasswordInput = wrapper.find('input[id="confirm-password"]');
await nameInput.setValue("Test User");
await emailInput.setValue("test@example.com");
await passwordInput.setValue("password123");
await confirmPasswordInput.setValue("password123");
// Submit
await wrapper.find("form").trigger("submit");
expect(authMocks.signUpEmail).toHaveBeenCalledWith({
name: "Test User",
email: "test@example.com",
password: "password123", // NOSONAR - Mocked value
});
});
});

View file

@ -0,0 +1,143 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, expect, it, vi, beforeEach, beforeAll, afterAll } from "vitest";
import LoginPage from "~/pages/member/auth/login.vue";
// Mock the auth store
const authStoreMocks = vi.hoisted(() => ({
signIn: vi.fn(),
lastError: null,
}));
vi.mock("~/stores/auth", () => ({
useAuthStore: () => authStoreMocks,
}));
// Make useAuthStore available globally for the component
globalThis.useAuthStore = () => authStoreMocks;
// Mock UI components
vi.mock("@/components/ui/button", () => ({
Button: {
props: ["variant", "type"],
template: '<button :type="type"><slot /></button>',
},
}));
vi.mock("@/components/ui/input", () => ({
Input: {
props: ["modelValue", "id", "type", "placeholder", "required"],
emits: ["update:modelValue"],
template: `<input :id="id" :type="type" :placeholder="placeholder" :required="required" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`,
},
}));
vi.mock("@/components/ui/card", () => ({
Card: { template: "<div><slot /></div>" },
CardHeader: { template: "<div><slot /></div>" },
CardTitle: { template: "<h1><slot /></h1>" },
CardDescription: { template: "<p><slot /></p>" },
CardContent: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/ui/field", () => ({
Field: {
props: ["variant"],
template: "<div><slot /></div>",
},
FieldGroup: { template: "<div><slot /></div>" },
FieldLabel: { template: "<label><slot /></label>" },
FieldDescription: { template: "<span><slot /></span>" },
}));
vi.mock("lucide-vue-next", () => ({
Frown: { template: "<svg></svg>" },
}));
describe("LoginPage", () => {
beforeAll(() => {
const shouldSuppress = (args: string[]) => {
const msg = args.join(" ");
return msg.includes("<Suspense> is an experimental feature");
};
const spyMethods = ["warn", "error", "log", "info"] as const;
for (const method of spyMethods) {
const original = console[method];
vi.spyOn(console, method).mockImplementation((...args) => {
if (shouldSuppress(args)) return;
original(...args);
});
}
});
afterAll(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
authStoreMocks.lastError = null;
});
it("renders the login form correctly", async () => {
const wrapper = mount({
components: { LoginPage },
template: "<Suspense><LoginPage /></Suspense>",
});
await flushPromises();
expect(wrapper.text()).toContain("Login");
expect(wrapper.text()).toContain("Enter your email below to login");
expect(wrapper.find('input[type="email"]').exists()).toBe(true);
expect(wrapper.find('input[type="password"]').exists()).toBe(true);
expect(wrapper.text()).toContain("Don't have an account?");
expect(wrapper.text()).toContain("Create account");
});
it("submits the form with correct credentials", async () => {
const wrapper = mount({
components: { LoginPage },
template: "<Suspense><LoginPage /></Suspense>",
});
await flushPromises();
// Fill the form
const emailInput = wrapper.find('input[id="email"]');
const passwordInput = wrapper.find('input[id="password"]');
await emailInput.setValue("test@example.com");
await passwordInput.setValue("password123");
// Submit the form
await wrapper.find("form").trigger("submit");
expect(authStoreMocks.signIn).toHaveBeenCalledWith("test@example.com", "password123");
});
it("displays error message when lastError is set", async () => {
authStoreMocks.lastError = "Invalid credentials";
const wrapper = mount({
components: { LoginPage },
template: "<Suspense><LoginPage /></Suspense>",
});
await flushPromises();
expect(wrapper.text()).toContain("Invalid credentials");
});
it("contains links to Terms of Service and Privacy Policy", async () => {
const wrapper = mount({
components: { LoginPage },
template: "<Suspense><LoginPage /></Suspense>",
});
await flushPromises();
expect(wrapper.text()).toContain("Terms of Service");
expect(wrapper.text()).toContain("Privacy Policy");
});
});

View file

@ -0,0 +1,91 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, expect, it, vi, beforeEach, beforeAll, afterAll } from "vitest";
import LogoutPage from "~/pages/member/auth/logout.vue";
// Mock the auth store
const mocks = vi.hoisted(() => ({
init: vi.fn(),
signOut: vi.fn(),
}));
vi.mock("~/stores/auth", () => ({
useAuthStore: () => ({
init: mocks.init,
signOut: mocks.signOut,
}),
}));
// Mock UI components
vi.mock("@/components/ui/button", () => ({
Button: {
template: "<button><slot /></button>",
},
}));
vi.mock("@/components/ui/card", () => ({
Card: { template: "<div><slot /></div>" },
CardContent: { template: "<div><slot /></div>" },
CardDescription: { template: "<div><slot /></div>" },
CardHeader: { template: "<div><slot /></div>" },
CardTitle: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/ui/field", () => ({
Field: { template: "<div><slot /></div>" },
FieldDescription: { template: "<div><slot /></div>" },
}));
describe("LogoutPage", () => {
beforeAll(() => {
const shouldSuppress = (args: string[]) => {
const msg = args.join(" ");
return msg.includes("<Suspense> is an experimental feature");
};
const spyMethods = ["warn", "error", "log", "info"] as const;
for (const method of spyMethods) {
const original = console[method];
vi.spyOn(console, method).mockImplementation((...args) => {
if (shouldSuppress(args)) return;
original(...args);
});
}
});
afterAll(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
it("renders correctly and calls init", async () => {
const wrapper = mount({
components: { LogoutPage },
template: "<Suspense><LogoutPage /></Suspense>",
});
await flushPromises();
expect(mocks.init).toHaveBeenCalled();
expect(wrapper.text()).toContain("Logout");
expect(wrapper.text()).toContain("Are you sure you want to logout?");
expect(wrapper.text()).toContain("Home");
});
it("calls signOut on form submit", async () => {
const wrapper = mount({
components: { LogoutPage },
template: "<Suspense><LogoutPage /></Suspense>",
});
await flushPromises();
const form = wrapper.find("form");
expect(form.exists()).toBe(true);
await form.trigger("submit");
expect(mocks.signOut).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,366 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { H3Event } from "h3";
// Mock the auth utility
const mocks = vi.hoisted(() => ({
authHandler: vi.fn(),
defineEventHandler: vi.fn((handler) => handler),
toWebRequest: vi.fn((event: H3Event) => {
// Create a mock Request object
const url = event.node.req.url || "/";
const method = event.node.req.method || "GET";
return new Request(`http://localhost${url}`, {
method,
headers: event.node.req.headers as HeadersInit,
});
}),
}));
vi.mock("~~/shared/utils/auth", () => ({
auth: {
handler: mocks.authHandler,
},
}));
// Mock H3 utilities
vi.mock("h3", async () => {
const actual = await vi.importActual<typeof import("h3")>("h3");
return {
...actual,
defineEventHandler: mocks.defineEventHandler,
toWebRequest: mocks.toWebRequest,
};
});
describe("Auth API Handler", () => {
let handler: any;
beforeEach(async () => {
vi.clearAllMocks();
// Set up global functions for Nuxt auto-imports
(globalThis as any).defineEventHandler = mocks.defineEventHandler;
(globalThis as any).toWebRequest = mocks.toWebRequest;
// Dynamically import the handler after mocks are set up
const module = await import("../../../server/api/[...auth]");
handler = module.default;
});
it("should be defined", () => {
expect(handler).toBeDefined();
expect(typeof handler).toBe("function");
});
it("should call auth.handler with converted web request", async () => {
// Mock H3Event
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/sign-in",
headers: {
"content-type": "application/json",
},
},
res: {},
},
context: {},
} as unknown as H3Event;
// Mock the response from auth.handler
const mockResponse = new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
// Call the handler
const result = await handler(mockEvent);
// Verify auth.handler was called
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
// Verify the result
expect(result).toBe(mockResponse);
});
it("should handle GET requests", async () => {
const mockEvent = {
node: {
req: {
method: "GET",
url: "/api/auth/session",
headers: {},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(JSON.stringify({ user: null }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
});
it("should handle POST requests for sign-in", async () => {
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/sign-in/email",
headers: {
"content-type": "application/json",
},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(
JSON.stringify({
user: {
id: "123",
email: "test@example.com",
name: "Test User",
},
session: { token: "abc123" },
}),
{
status: 200,
headers: { "content-type": "application/json" },
}
);
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
});
it("should handle POST requests for sign-up", async () => {
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/sign-up/email",
headers: {
"content-type": "application/json",
},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(
JSON.stringify({
user: {
id: "456",
email: "newuser@example.com",
name: "New User",
},
session: { token: "xyz789" },
}),
{
status: 201,
headers: { "content-type": "application/json" },
}
);
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
});
it("should handle POST requests for sign-out", async () => {
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/sign-out",
headers: {
"content-type": "application/json",
},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
});
it("should handle error responses from auth.handler", async () => {
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/sign-in/email",
headers: {
"content-type": "application/json",
},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockErrorResponse = new Response(
JSON.stringify({
error: "Invalid credentials",
}),
{
status: 401,
headers: { "content-type": "application/json" },
}
);
mocks.authHandler.mockResolvedValue(mockErrorResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockErrorResponse);
});
it("should handle different HTTP methods", async () => {
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"];
for (const method of methods) {
vi.clearAllMocks();
const mockEvent = {
node: {
req: {
method,
url: "/api/auth/test",
headers: {},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(JSON.stringify({ method }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
}
});
it("should convert H3Event to Web Request correctly", async () => {
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/test",
headers: {
"content-type": "application/json",
authorization: "Bearer token123",
},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
await handler(mockEvent);
// Verify that auth.handler was called with a Request object
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
const callArg = mocks.authHandler.mock.calls[0][0];
// The argument should be a Request object (from toWebRequest conversion)
expect(callArg).toBeDefined();
});
it("should handle requests with query parameters", async () => {
const mockEvent = {
node: {
req: {
method: "GET",
url: "/api/auth/session?redirect=/dashboard",
headers: {},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(JSON.stringify({ user: null }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
});
it("should handle requests with different content types", async () => {
const contentTypes = ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"];
for (const contentType of contentTypes) {
vi.clearAllMocks();
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/sign-in",
headers: {
"content-type": contentType,
},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
}
});
});

View file

@ -1,5 +1,13 @@
import { vi } from "vitest";
import { config } from "@vue/test-utils";
import * as vue from "vue";
(global as any).ref = vue.ref;
(global as any).reactive = vue.reactive;
(global as any).computed = vue.computed;
(global as any).watch = vue.watch;
(global as any).onMounted = vue.onMounted;
(global as any).onUnmounted = vue.onUnmounted;
Object.defineProperty(global, "import", {
value: {
@ -15,5 +23,6 @@ config.global.stubs = {
NuxtPage: true,
Divider: true,
NuxtRouteAnnouncer: true,
NuxtLink: { template: "<a><slot /></a>" },
NuxtLink: { template: "<a><slot /></a>", props: ["to"] },
ClientOnly: { template: "<div class='client-only'><slot /></div>" },
};

View file

@ -0,0 +1,133 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// Mock uuid
const mockUuidv7 = vi.fn(() => "test-uuid-v7");
vi.mock("uuid", () => ({
v7: mockUuidv7,
}));
// Mock database
const mockDb = {
query: vi.fn(),
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
vi.mock("#shared/utils/db/index", () => ({
default: mockDb,
}));
// Mock better-auth
const mockBetterAuth = vi.fn((config) => ({
handler: vi.fn(),
api: vi.fn(),
config,
$Infer: {} as any,
}));
const mockDrizzleAdapter = vi.fn((db, options) => ({
db,
options,
type: "drizzle-adapter",
}));
vi.mock("better-auth", () => ({
betterAuth: mockBetterAuth,
}));
vi.mock("better-auth/adapters/drizzle", () => ({
drizzleAdapter: mockDrizzleAdapter,
}));
describe("auth utility", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should create betterAuth instance with correct configuration", async () => {
// Import the auth module (this will trigger the betterAuth call)
await import("#shared/utils/auth");
// Verify betterAuth was called
expect(mockBetterAuth).toHaveBeenCalledTimes(1);
// Get the configuration passed to betterAuth
const config = mockBetterAuth.mock.calls[0][0];
// Verify the configuration structure
expect(config).toBeDefined();
expect(config).toHaveProperty("database");
expect(config).toHaveProperty("advanced");
expect(config).toHaveProperty("emailAndPassword");
// Verify only emailAndPassword is configured
expect(config).not.toHaveProperty("oauth");
expect(config).not.toHaveProperty("magicLink");
expect(config).not.toHaveProperty("twoFactor");
// Verify nested properties
expect(config.advanced).toHaveProperty("database");
expect(config.advanced.database).toHaveProperty("generateId");
expect(config.emailAndPassword).toHaveProperty("enabled");
// Verify drizzleAdapter was called with correct arguments
expect(mockDrizzleAdapter).toHaveBeenCalledTimes(1);
expect(mockDrizzleAdapter).toHaveBeenCalledWith(mockDb, {
provider: "pg",
});
// Verify emailAndPassword is enabled
expect(config.emailAndPassword).toEqual({
enabled: true,
});
// Verify advanced.database.generateId is a function
expect(config.advanced).toBeDefined();
expect(config.advanced.database).toBeDefined();
expect(config.advanced.database.generateId).toBeTypeOf("function");
// Test the generateId function
const generatedId = config.advanced.database.generateId();
expect(mockUuidv7).toHaveBeenCalled();
expect(generatedId).toBe("test-uuid-v7");
// Get the drizzle adapter call
const dbInstance = mockDrizzleAdapter.mock.calls[0][0];
expect(dbInstance).toBe(mockDb);
});
it("should export auth instance with expected properties", async () => {
// Import the auth module
const { auth } = await import("#shared/utils/auth");
// Verify the auth instance has expected properties
expect(auth).toBeDefined();
expect(auth).toHaveProperty("handler");
expect(auth).toHaveProperty("api");
expect(auth).toHaveProperty("config");
});
describe("module exports", () => {
it("should export auth as named export", async () => {
// Import the auth module
const authModule = await import("#shared/utils/auth");
// Verify named export exists
expect(authModule).toHaveProperty("auth");
expect(authModule.auth).toBeDefined();
});
it("should not have default export", async () => {
// Import the auth module
const authModule = await import("#shared/utils/auth");
// Verify no default export (or it's the same as named export)
// In ES modules, default export would be authModule.default
// @ts-expect-error The description must be 10 characters or longer
expect(authModule.default).toBeUndefined();
});
});
});

446
tests/stores/auth.test.ts Normal file
View file

@ -0,0 +1,446 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { setActivePinia, createPinia } from "pinia";
import { useAuthStore } from "~/stores/auth";
// Mock better-auth/vue
const { mockUseSession, mockSignInEmail, mockSignOut } = vi.hoisted(() => ({
mockUseSession: vi.fn(),
mockSignInEmail: vi.fn(),
mockSignOut: vi.fn(),
}));
vi.mock("better-auth/vue", () => ({
createAuthClient: () => ({
useSession: mockUseSession,
signIn: {
email: mockSignInEmail,
},
signOut: mockSignOut,
}),
}));
// Mock navigateTo
const navigateToMock = vi.fn();
vi.stubGlobal("navigateTo", navigateToMock);
// Mock useFetch
const useFetchMock = vi.fn();
vi.stubGlobal("useFetch", useFetchMock);
describe("useAuthStore", () => {
beforeEach(() => {
// Create a fresh pinia instance for each test
setActivePinia(createPinia());
vi.clearAllMocks();
});
describe("init", () => {
it("should initialize session with user data", async () => {
const mockSessionData = {
data: {
user: {
id: "123",
name: "Test User",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
image: "avatar.png",
},
session: {
id: "session-123",
userId: "123",
expiresAt: new Date(),
token: "token-123",
ipAddress: "127.0.0.1",
userAgent: "test-agent",
},
},
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(mockUseSession).toHaveBeenCalledWith(useFetchMock);
expect(store.user).toEqual(mockSessionData.data.user);
expect(store.loading).toBe(false);
expect(store.lastError).toBeUndefined();
});
it("should handle session with no user (logged out state)", async () => {
const mockSessionData = {
data: null,
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(mockUseSession).toHaveBeenCalledWith(useFetchMock);
expect(store.user).toBeUndefined();
expect(store.loading).toBe(false);
expect(store.lastError).toBeUndefined();
});
it("should clear lastError when init is called", async () => {
const mockSessionData = {
data: null,
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
// Set an error first
store.lastError = "Previous error";
await store.init();
expect(store.lastError).toBeUndefined();
});
it("should handle pending session state", async () => {
const mockSessionData = {
data: null,
isPending: true,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(store.loading).toBe(true);
});
});
describe("signIn", () => {
it("should successfully sign in with valid credentials", async () => {
mockSignInEmail.mockResolvedValue({ error: null });
const store = useAuthStore();
await store.signIn("test@example.com", "password123");
expect(mockSignInEmail).toHaveBeenCalledWith({
email: "test@example.com",
password: "password123", // NOSONAR - Mocked value
callbackURL: "/",
});
expect(store.lastError).toBeUndefined();
});
it("should set lastError when sign in fails", async () => {
const errorMessage = "Invalid credentials";
mockSignInEmail.mockResolvedValue({
error: { message: errorMessage },
});
const store = useAuthStore();
await store.signIn("test@example.com", "wrongpassword");
expect(mockSignInEmail).toHaveBeenCalledWith({
email: "test@example.com",
password: "wrongpassword", // NOSONAR - Mocked value
callbackURL: "/",
});
expect(store.lastError).toBe(errorMessage);
});
it("should handle network errors during sign in", async () => {
const errorMessage = "Network error";
mockSignInEmail.mockResolvedValue({
error: { message: errorMessage },
});
const store = useAuthStore();
await store.signIn("test@example.com", "password123");
expect(store.lastError).toBe(errorMessage);
});
it("should clear previous error on successful sign in", async () => {
mockSignInEmail.mockResolvedValue({ error: null });
const store = useAuthStore();
store.lastError = "Previous error";
await store.signIn("test@example.com", "password123");
// Note: lastError is only set when there's an error, not cleared on success
// This test documents the current behavior
expect(store.lastError).toBe("Previous error");
});
});
describe("signOut", () => {
it("should call signOut and navigate to home", async () => {
mockSignOut.mockResolvedValue({});
const store = useAuthStore();
await store.signOut();
expect(mockSignOut).toHaveBeenCalledWith({});
expect(navigateToMock).toHaveBeenCalledWith("/");
});
it("should navigate to home even if signOut fails", async () => {
mockSignOut.mockRejectedValue(new Error("Sign out failed"));
const store = useAuthStore();
// The current implementation doesn't handle errors, so this will throw
await expect(store.signOut()).rejects.toThrow("Sign out failed");
// navigateTo is not called because the error is thrown before it
expect(navigateToMock).not.toHaveBeenCalled();
});
});
describe("computed properties", () => {
it("should return user from session data", async () => {
const mockUser = {
id: "123",
name: "Test User",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
image: "avatar.png",
};
const mockSessionData = {
data: {
user: mockUser,
session: {
id: "session-123",
userId: "123",
expiresAt: new Date(),
token: "token-123",
ipAddress: "127.0.0.1",
userAgent: "test-agent",
},
},
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(store.user).toEqual(mockUser);
});
it("should return undefined when no user is logged in", async () => {
const mockSessionData = {
data: null,
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(store.user).toBeUndefined();
});
it("should return loading state from session", async () => {
const mockSessionData = {
data: null,
isPending: true,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(store.loading).toBe(true);
});
it("should return false for loading when session is loaded", async () => {
const mockSessionData = {
data: {
user: {
id: "123",
name: "Test User",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
image: "avatar.png",
},
session: {
id: "session-123",
userId: "123",
expiresAt: new Date(),
token: "token-123",
ipAddress: "127.0.0.1",
userAgent: "test-agent",
},
},
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(store.loading).toBe(false);
});
});
describe("store state management", () => {
it("should maintain state across multiple operations", async () => {
const mockUser = {
id: "123",
name: "Test User",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
image: "avatar.png",
};
const mockSessionData = {
data: {
user: mockUser,
session: {
id: "session-123",
userId: "123",
expiresAt: new Date(),
token: "token-123",
ipAddress: "127.0.0.1",
userAgent: "test-agent",
},
},
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
mockSignInEmail.mockResolvedValue({ error: null });
const store = useAuthStore();
// Initialize
await store.init();
expect(store.user).toEqual(mockUser);
// Sign in
await store.signIn("test@example.com", "password123");
expect(store.user).toEqual(mockUser); // User should still be there
// Sign out
mockSignOut.mockResolvedValue({});
await store.signOut();
expect(navigateToMock).toHaveBeenCalledWith("/");
});
it("should handle error state persistence", async () => {
const errorMessage = "Authentication failed";
mockSignInEmail.mockResolvedValue({
error: { message: errorMessage },
});
const store = useAuthStore();
// First failed sign in
await store.signIn("test@example.com", "wrongpassword");
expect(store.lastError).toBe(errorMessage);
// Error should persist
expect(store.lastError).toBe(errorMessage);
// Init should clear the error
mockUseSession.mockResolvedValue({
data: null,
isPending: false,
error: null,
});
await store.init();
expect(store.lastError).toBeUndefined();
});
});
describe("edge cases", () => {
it("should handle empty email and password", async () => {
mockSignInEmail.mockResolvedValue({ error: null });
const store = useAuthStore();
await store.signIn("", "");
expect(mockSignInEmail).toHaveBeenCalledWith({
email: "",
password: "",
callbackURL: "/",
});
});
it("should handle special characters in credentials", async () => {
mockSignInEmail.mockResolvedValue({ error: null });
const store = useAuthStore();
const specialEmail = "test+special@example.com";
const specialPassword = "p@ssw0rd!#$%"; // NOSONAR - Mocked value
await store.signIn(specialEmail, specialPassword);
expect(mockSignInEmail).toHaveBeenCalledWith({
email: specialEmail,
password: specialPassword,
callbackURL: "/",
});
});
it("should handle session data with missing user properties", async () => {
const mockSessionData = {
data: {
user: {
id: "123",
name: "Test User",
email: "test@example.com",
emailVerified: false,
createdAt: new Date(),
updatedAt: new Date(),
// image is optional and missing
},
session: {
id: "session-123",
userId: "123",
expiresAt: new Date(),
token: "token-123",
ipAddress: "127.0.0.1",
userAgent: "test-agent",
},
},
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(store.user).toBeDefined();
expect(store.user?.id).toBe("123");
expect(store.user?.image).toBeUndefined();
});
});
});