feat: implement AI styling foundation and billing scope rework
This commit is contained in:
81
resources/js/guest-v2/services/__tests__/aiEditsApi.test.ts
Normal file
81
resources/js/guest-v2/services/__tests__/aiEditsApi.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchJsonMock = vi.fn();
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
fetchJson: (...args: unknown[]) => fetchJsonMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/device', () => ({
|
||||
getDeviceId: () => 'device-123',
|
||||
}));
|
||||
|
||||
import { createGuestAiEdit, fetchGuestAiEditStatus, fetchGuestAiStyles } from '../aiEditsApi';
|
||||
|
||||
describe('aiEditsApi', () => {
|
||||
beforeEach(() => {
|
||||
fetchJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('loads guest ai styles with device header', async () => {
|
||||
fetchJsonMock.mockResolvedValue({
|
||||
data: {
|
||||
data: [{ id: 10, key: 'style-a', name: 'Style A' }],
|
||||
meta: { allow_custom_prompt: false },
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await fetchGuestAiStyles('token-abc');
|
||||
|
||||
expect(fetchJsonMock).toHaveBeenCalledWith('/api/v1/events/token-abc/ai-styles', {
|
||||
headers: {
|
||||
'X-Device-Id': 'device-123',
|
||||
},
|
||||
noStore: true,
|
||||
});
|
||||
expect(payload.data).toHaveLength(1);
|
||||
expect(payload.data[0]?.key).toBe('style-a');
|
||||
expect(payload.meta.allow_custom_prompt).toBe(false);
|
||||
});
|
||||
|
||||
it('creates guest ai edit with json payload', async () => {
|
||||
fetchJsonMock.mockResolvedValue({
|
||||
data: {
|
||||
duplicate: false,
|
||||
data: {
|
||||
id: 55,
|
||||
event_id: 1,
|
||||
photo_id: 9,
|
||||
status: 'queued',
|
||||
outputs: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await createGuestAiEdit('token-abc', 9, {
|
||||
style_key: 'style-a',
|
||||
idempotency_key: 'demo-key',
|
||||
});
|
||||
|
||||
expect(fetchJsonMock).toHaveBeenCalledWith('/api/v1/events/token-abc/photos/9/ai-edits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Device-Id': 'device-123',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
style_key: 'style-a',
|
||||
idempotency_key: 'demo-key',
|
||||
}),
|
||||
noStore: true,
|
||||
});
|
||||
expect(payload.data.id).toBe(55);
|
||||
expect(payload.data.status).toBe('queued');
|
||||
});
|
||||
|
||||
it('throws when status payload is malformed', async () => {
|
||||
fetchJsonMock.mockResolvedValue({ data: null });
|
||||
|
||||
await expect(fetchGuestAiEditStatus('token-abc', 55)).rejects.toThrow('AI edit status response is invalid.');
|
||||
});
|
||||
});
|
||||
142
resources/js/guest-v2/services/aiEditsApi.ts
Normal file
142
resources/js/guest-v2/services/aiEditsApi.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { fetchJson } from './apiClient';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
export type GuestAiStyle = {
|
||||
id: number;
|
||||
key: string;
|
||||
name: string;
|
||||
category?: string | null;
|
||||
description?: string | null;
|
||||
provider?: string | null;
|
||||
provider_model?: string | null;
|
||||
requires_source_image?: boolean;
|
||||
is_premium?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GuestAiStylesMeta = {
|
||||
required_feature?: string | null;
|
||||
addon_keys?: string[] | null;
|
||||
allow_custom_prompt?: boolean;
|
||||
allowed_style_keys?: string[] | null;
|
||||
policy_message?: string | null;
|
||||
};
|
||||
|
||||
export type GuestAiEditOutput = {
|
||||
id: number;
|
||||
storage_disk?: string | null;
|
||||
storage_path?: string | null;
|
||||
provider_url?: string | null;
|
||||
mime_type?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
is_primary?: boolean;
|
||||
safety_state?: string | null;
|
||||
safety_reasons?: string[];
|
||||
generated_at?: string | null;
|
||||
};
|
||||
|
||||
export type GuestAiEditRequest = {
|
||||
id: number;
|
||||
event_id: number;
|
||||
photo_id: number;
|
||||
style?: {
|
||||
id: number;
|
||||
key: string;
|
||||
name: string;
|
||||
} | null;
|
||||
provider?: string | null;
|
||||
provider_model?: string | null;
|
||||
status: 'queued' | 'processing' | 'succeeded' | 'failed' | 'blocked' | 'canceled' | string;
|
||||
safety_state?: string | null;
|
||||
safety_reasons?: string[];
|
||||
failure_code?: string | null;
|
||||
failure_message?: string | null;
|
||||
queued_at?: string | null;
|
||||
started_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
outputs: GuestAiEditOutput[];
|
||||
};
|
||||
|
||||
export type GuestAiStylesResponse = {
|
||||
data: GuestAiStyle[];
|
||||
meta: GuestAiStylesMeta;
|
||||
};
|
||||
|
||||
export type GuestAiEditEnvelope = {
|
||||
message?: string;
|
||||
duplicate?: boolean;
|
||||
data: GuestAiEditRequest;
|
||||
};
|
||||
|
||||
function deviceHeaders(): Record<string, string> {
|
||||
return {
|
||||
'X-Device-Id': getDeviceId(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGuestAiStyles(eventToken: string): Promise<GuestAiStylesResponse> {
|
||||
const response = await fetchJson<GuestAiStylesResponse>(
|
||||
`/api/v1/events/${encodeURIComponent(eventToken)}/ai-styles`,
|
||||
{
|
||||
headers: deviceHeaders(),
|
||||
noStore: true,
|
||||
}
|
||||
);
|
||||
|
||||
const payload = response.data;
|
||||
|
||||
return {
|
||||
data: Array.isArray(payload?.data) ? payload.data : [],
|
||||
meta: payload?.meta && typeof payload.meta === 'object' ? payload.meta : {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createGuestAiEdit(
|
||||
eventToken: string,
|
||||
photoId: number,
|
||||
payload: {
|
||||
style_key?: string;
|
||||
prompt?: string;
|
||||
negative_prompt?: string;
|
||||
provider_model?: string;
|
||||
idempotency_key?: string;
|
||||
session_id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<GuestAiEditEnvelope> {
|
||||
const response = await fetchJson<GuestAiEditEnvelope>(
|
||||
`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/ai-edits`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...deviceHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
noStore: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data || typeof response.data !== 'object' || !response.data.data) {
|
||||
throw new Error('AI edit request response is invalid.');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchGuestAiEditStatus(eventToken: string, requestId: number): Promise<{ data: GuestAiEditRequest }> {
|
||||
const response = await fetchJson<{ data: GuestAiEditRequest }>(
|
||||
`/api/v1/events/${encodeURIComponent(eventToken)}/ai-edits/${requestId}`,
|
||||
{
|
||||
headers: deviceHeaders(),
|
||||
noStore: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data || typeof response.data !== 'object' || !response.data.data) {
|
||||
throw new Error('AI edit status response is invalid.');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
Reference in New Issue
Block a user