feat: implement AI styling foundation and billing scope rework
This commit is contained in:
229
resources/js/guest-v2/__tests__/AiMagicEditSheet.test.tsx
Normal file
229
resources/js/guest-v2/__tests__/AiMagicEditSheet.test.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const fetchGuestAiStylesMock = vi.fn();
|
||||
const createGuestAiEditMock = vi.fn();
|
||||
const fetchGuestAiEditStatusMock = vi.fn();
|
||||
|
||||
const translate = (key: string, options?: unknown, fallback?: string) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof options === 'string') {
|
||||
return options;
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
vi.mock('../services/aiEditsApi', () => ({
|
||||
fetchGuestAiStyles: (...args: unknown[]) => fetchGuestAiStylesMock(...args),
|
||||
createGuestAiEdit: (...args: unknown[]) => createGuestAiEditMock(...args),
|
||||
fetchGuestAiEditStatus: (...args: unknown[]) => fetchGuestAiEditStatusMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: translate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/toast', () => ({
|
||||
pushGuestToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/guestTheme', () => ({
|
||||
useGuestThemeVariant: () => ({ isDark: false }),
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Copy: () => <span>copy</span>,
|
||||
Download: () => <span>download</span>,
|
||||
Loader2: () => <span>loader</span>,
|
||||
MessageSquare: () => <span>message</span>,
|
||||
RefreshCcw: () => <span>refresh</span>,
|
||||
Share2: () => <span>share</span>,
|
||||
Sparkles: () => <span>sparkles</span>,
|
||||
Wand2: () => <span>wand</span>,
|
||||
X: () => <span>x</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/sheet', () => {
|
||||
const Sheet = ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null);
|
||||
|
||||
Sheet.Overlay = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
|
||||
Sheet.Frame = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
|
||||
Sheet.Handle = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
|
||||
|
||||
return { Sheet };
|
||||
});
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/button', () => ({
|
||||
Button: ({ children, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import AiMagicEditSheet from '../components/AiMagicEditSheet';
|
||||
|
||||
describe('AiMagicEditSheet', () => {
|
||||
const originalOnLine = navigator.onLine;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchGuestAiStylesMock.mockReset();
|
||||
createGuestAiEditMock.mockReset();
|
||||
fetchGuestAiEditStatusMock.mockReset();
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
value: originalOnLine,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads styles and creates an ai edit request', async () => {
|
||||
fetchGuestAiStylesMock.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
key: 'ghibli-soft',
|
||||
name: 'Ghibli Soft',
|
||||
description: 'Soft shading style',
|
||||
},
|
||||
],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
createGuestAiEditMock.mockResolvedValue({
|
||||
duplicate: false,
|
||||
data: {
|
||||
id: 15,
|
||||
event_id: 2,
|
||||
photo_id: 7,
|
||||
status: 'succeeded',
|
||||
style: { id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' },
|
||||
outputs: [{ id: 99, provider_url: 'https://example.com/ai.jpg', is_primary: true }],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AiMagicEditSheet
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
eventToken="event-token"
|
||||
photoId={7}
|
||||
originalImageUrl="/storage/original.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Ghibli Soft')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Generate AI edit'));
|
||||
|
||||
await waitFor(() => expect(createGuestAiEditMock).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(screen.getByText('AI result')).toBeInTheDocument());
|
||||
expect(screen.getByText('Copy link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error when style loading fails', async () => {
|
||||
fetchGuestAiStylesMock.mockRejectedValue(new Error('Styles not reachable'));
|
||||
|
||||
render(
|
||||
<AiMagicEditSheet
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
eventToken="event-token"
|
||||
photoId={7}
|
||||
originalImageUrl="/storage/original.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Styles not reachable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pauses polling while offline and resumes after reconnect', async () => {
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
|
||||
fetchGuestAiStylesMock.mockResolvedValue({
|
||||
data: [{ id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' }],
|
||||
meta: {},
|
||||
});
|
||||
createGuestAiEditMock.mockResolvedValue({
|
||||
duplicate: false,
|
||||
data: {
|
||||
id: 22,
|
||||
event_id: 2,
|
||||
photo_id: 7,
|
||||
status: 'processing',
|
||||
style: { id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' },
|
||||
outputs: [],
|
||||
},
|
||||
});
|
||||
fetchGuestAiEditStatusMock.mockResolvedValue({
|
||||
data: {
|
||||
id: 22,
|
||||
event_id: 2,
|
||||
photo_id: 7,
|
||||
status: 'succeeded',
|
||||
style: { id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' },
|
||||
outputs: [{ id: 7, provider_url: 'https://example.com/generated.jpg', is_primary: true }],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AiMagicEditSheet
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
eventToken="event-token"
|
||||
photoId={7}
|
||||
originalImageUrl="/storage/original.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Ghibli Soft')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Generate AI edit'));
|
||||
|
||||
await waitFor(() => expect(createGuestAiEditMock).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText('You are offline. Status updates resume automatically when connection is back.')).toBeInTheDocument();
|
||||
|
||||
vi.useFakeTimers();
|
||||
await vi.advanceTimersByTimeAsync(7000);
|
||||
expect(fetchGuestAiEditStatusMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('online'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(fetchGuestAiEditStatusMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const setSearchParamsMock = vi.fn();
|
||||
const pushGuestToastMock = vi.fn();
|
||||
const mockEventData = {
|
||||
token: 'demo',
|
||||
event: { name: 'Demo Event', capabilities: { ai_styling: false } },
|
||||
};
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
@@ -11,7 +15,7 @@ vi.mock('react-router-dom', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../context/EventDataContext', () => ({
|
||||
useEventData: () => ({ token: 'demo', event: { name: 'Demo Event' } }),
|
||||
useEventData: () => mockEventData,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/usePollGalleryDelta', () => ({
|
||||
@@ -73,6 +77,10 @@ vi.mock('../components/ShareSheet', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/AiMagicEditSheet', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../lib/toast', () => ({
|
||||
pushGuestToast: (...args: unknown[]) => pushGuestToastMock(...args),
|
||||
}));
|
||||
@@ -115,6 +123,8 @@ describe('GalleryScreen', () => {
|
||||
pushGuestToastMock.mockClear();
|
||||
fetchGalleryMock.mockReset();
|
||||
fetchPhotoMock.mockReset();
|
||||
mockEventData.token = 'demo';
|
||||
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: false } };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -160,4 +170,33 @@ describe('GalleryScreen', () => {
|
||||
expect(setSearchParamsMock).not.toHaveBeenCalled();
|
||||
expect(pushGuestToastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show ai magic edit action when ai styling is not entitled', async () => {
|
||||
fetchGalleryMock.mockResolvedValue({
|
||||
data: [{ id: 123, thumbnail_url: '/storage/demo.jpg', likes_count: 2 }],
|
||||
});
|
||||
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
|
||||
|
||||
render(<GalleryScreen />);
|
||||
|
||||
await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ai magic edit action hidden while rollout flag is disabled', async () => {
|
||||
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: true } };
|
||||
fetchGalleryMock.mockResolvedValue({
|
||||
data: [{ id: 123, thumbnail_url: '/storage/demo.jpg', likes_count: 2 }],
|
||||
});
|
||||
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
|
||||
|
||||
render(<GalleryScreen />);
|
||||
|
||||
await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@ import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const mockEventData = {
|
||||
token: 'token',
|
||||
event: { name: 'Demo Event', capabilities: { ai_styling: false } },
|
||||
};
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useParams: () => ({ photoId: '123' }),
|
||||
useNavigate: () => vi.fn(),
|
||||
@@ -36,8 +41,12 @@ vi.mock('../components/ShareSheet', () => ({
|
||||
default: () => <div>ShareSheet</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/AiMagicEditSheet', () => ({
|
||||
default: () => <div>AiMagicEditSheet</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../context/EventDataContext', () => ({
|
||||
useEventData: () => ({ token: 'token', event: { name: 'Demo Event' } }),
|
||||
useEventData: () => mockEventData,
|
||||
}));
|
||||
|
||||
vi.mock('../services/photosApi', () => ({
|
||||
@@ -66,9 +75,18 @@ import PhotoLightboxScreen from '../screens/PhotoLightboxScreen';
|
||||
|
||||
describe('PhotoLightboxScreen', () => {
|
||||
it('renders lightbox layout', async () => {
|
||||
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: false } };
|
||||
render(<PhotoLightboxScreen />);
|
||||
|
||||
expect(await screen.findByText('Gallery')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Like')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps ai magic edit action hidden while rollout flag is disabled', async () => {
|
||||
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: true } };
|
||||
render(<PhotoLightboxScreen />);
|
||||
|
||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user