diff --git a/e2e/timezone-mismatch.spec.ts b/e2e/timezone-mismatch.spec.ts new file mode 100644 index 00000000..e7091043 --- /dev/null +++ b/e2e/timezone-mismatch.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import type { Page, TestContext } from '../playwright/fixtures'; +import { getCurrentUserViaApi, updateUserProfileViaApi } from './utils/api'; + +const BROWSER_TIMEZONE = 'Europe/Vienna'; +const MISMATCHED_TIMEZONE = 'America/New_York'; + +test.use({ timezoneId: BROWSER_TIMEZONE }); + +function mismatchModal(page: Page) { + return page.getByRole('dialog').filter({ hasText: 'Timezone mismatch detected' }); +} + +async function openPageWithTimezoneMismatch(page: Page, ctx: TestContext) { + await updateUserProfileViaApi(ctx, { timezone: MISMATCHED_TIMEZONE }); + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(mismatchModal(page)).toBeVisible(); +} + +test('timezone mismatch modal saves the device timezone through the users API', async ({ + page, + ctx, +}) => { + await openPageWithTimezoneMismatch(page, ctx); + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/api/v1/users/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + mismatchModal(page).getByRole('button', { name: 'Update timezone' }).click(), + ]); + await expect(mismatchModal(page)).toBeHidden(); + + const user = await getCurrentUserViaApi(ctx); + expect(user.timezone).toBe(BROWSER_TIMEZONE); + + // After the automatic reload the timezones match again, so the modal stays gone. + await page.waitForLoadState('load'); + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await expect(mismatchModal(page)).toBeHidden(); +}); + +test('timezone mismatch modal does not open for a different timezone with the same time', async ({ + page, + ctx, +}) => { + // Berlin and Vienna share the same offset and DST rules, so the times match. + await updateUserProfileViaApi(ctx, { timezone: 'Europe/Berlin' }); + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + + // Wait until the dashboard is rendered so the modal had its chance to mount. + await expect(page.getByTestId('dashboard_view')).toBeVisible(); + await expect(mismatchModal(page)).toBeHidden(); +}); + +test('timezone mismatch modal stays open when the update fails', async ({ page, ctx }) => { + await openPageWithTimezoneMismatch(page, ctx); + + await page.route('**/api/v1/users/*', (route) => { + if (route.request().method() === 'PUT') { + return route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Server error' }), + }); + } + return route.fallback(); + }); + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/api/v1/users/') && + response.request().method() === 'PUT' && + response.status() === 500 + ), + mismatchModal(page).getByRole('button', { name: 'Update timezone' }).click(), + ]); + + await expect(mismatchModal(page)).toBeVisible(); + await expect(page.getByText('Failed to update profile')).toBeVisible(); + + const user = await getCurrentUserViaApi(ctx); + expect(user.timezone).toBe(MISMATCHED_TIMEZONE); +}); diff --git a/resources/js/Components/Common/User/UserTimezoneMismatchModal.test.ts b/resources/js/Components/Common/User/UserTimezoneMismatchModal.test.ts deleted file mode 100644 index b36590fc..00000000 --- a/resources/js/Components/Common/User/UserTimezoneMismatchModal.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { flushPromises, shallowMount } from '@vue/test-utils'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createPinia } from 'pinia'; -import { VueQueryPlugin } from '@tanstack/vue-query'; -import UserTimezoneMismatchModal from './UserTimezoneMismatchModal.vue'; -import TimezoneMismatchModal from '@/packages/ui/src/TimezoneMismatchModal.vue'; -import { api } from '@/packages/api/src'; - -vi.mock('@/packages/api/src', async (importOriginal) => ({ - ...(await importOriginal()), - api: { - updateUser: vi.fn(), - }, -})); - -vi.mock('@inertiajs/vue3', async (importOriginal) => ({ - ...(await importOriginal()), - usePage: () => ({ - props: { - auth: { - user: { id: 'user-1' }, - }, - }, - }), -})); - -function mountModal() { - return shallowMount(UserTimezoneMismatchModal, { - props: { show: true }, - global: { - plugins: [createPinia(), VueQueryPlugin], - }, - }); -} - -describe('UserTimezoneMismatchModal', () => { - beforeEach(() => { - vi.mocked(api.updateUser).mockReset(); - Object.defineProperty(window.location, 'reload', { - configurable: true, - value: vi.fn(), - }); - }); - - it('saves the new timezone through the users API', async () => { - vi.mocked(api.updateUser).mockResolvedValue({ - data: { id: 'user-1', timezone: 'Australia/Sydney' }, - } as never); - - const wrapper = mountModal(); - wrapper.findComponent(TimezoneMismatchModal).vm.$emit('update', 'Australia/Sydney'); - await flushPromises(); - - expect(api.updateUser).toHaveBeenCalledWith( - { timezone: 'Australia/Sydney' }, - { params: { user: 'user-1' } } - ); - expect(wrapper.findComponent(TimezoneMismatchModal).props('show')).toBe(false); - expect(window.location.reload).toHaveBeenCalled(); - }); - - it('leaves the modal open when the update fails', async () => { - vi.mocked(api.updateUser).mockRejectedValue(new Error('nope')); - - const wrapper = mountModal(); - wrapper.findComponent(TimezoneMismatchModal).vm.$emit('update', 'Australia/Sydney'); - await flushPromises(); - - expect(wrapper.findComponent(TimezoneMismatchModal).props('show')).toBe(true); - expect(window.location.reload).not.toHaveBeenCalled(); - }); -});