211 lines
7.6 KiB
JavaScript
211 lines
7.6 KiB
JavaScript
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
|
import { flushPromises, mount } from '@vue/test-utils';
|
|||
|
|
|
|||
|
|
vi.mock('@inertiajs/vue3', async () => import('../../stubs/inertia.js'));
|
|||
|
|
|
|||
|
|
import Show from '../../../../resources/js/Pages/Prompts/Show.vue';
|
|||
|
|
import { inertiaMock, resetInertiaMock } from '../../stubs/inertia.js';
|
|||
|
|
|
|||
|
|
const promptFixture = {
|
|||
|
|
id: 7,
|
|||
|
|
name: 'Greeter',
|
|||
|
|
description: 'Says hi.',
|
|||
|
|
body: 'Hi {{ name }}!',
|
|||
|
|
is_favorite: true,
|
|||
|
|
usage_count: 4,
|
|||
|
|
last_used_at: '2026-10-01T09:30:00.000000Z',
|
|||
|
|
created_at: '2026-09-01T08:00:00.000000Z',
|
|||
|
|
updated_at: '2026-10-20T10:00:00.000000Z',
|
|||
|
|
tags: [{ id: 1, name: 'chat' }],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const baseProps = (overrides = {}) => ({
|
|||
|
|
prompt: { ...promptFixture, ...overrides },
|
|||
|
|
variables: ['name'],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const last = () => inertiaMock.routerCalls[inertiaMock.routerCalls.length - 1];
|
|||
|
|
|
|||
|
|
const installClipboard = (fail = false) => {
|
|||
|
|
const writeText = vi.fn(
|
|||
|
|
fail ? () => Promise.reject(new Error('Permission denied')) : () => Promise.resolve()
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
vi.stubGlobal('navigator', { clipboard: { writeText } });
|
|||
|
|
return writeText;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const buttonWithText = (wrapper, text) =>
|
|||
|
|
wrapper.findAll('button').find((button) => button.text() === text);
|
|||
|
|
|
|||
|
|
describe('Pages/Prompts/Show', () => {
|
|||
|
|
beforeEach(() => {
|
|||
|
|
resetInertiaMock();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
afterEach(() => {
|
|||
|
|
vi.unstubAllGlobals();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('renders the prompt metadata, tag links, and body', () => {
|
|||
|
|
const wrapper = mount(Show, { props: baseProps() });
|
|||
|
|
|
|||
|
|
expect(inertiaMock.headCalls.at(-1).title).toBe('Greeter');
|
|||
|
|
expect(wrapper.find('h1').text()).toBe('Greeter');
|
|||
|
|
expect(wrapper.text()).toContain('Says hi.');
|
|||
|
|
expect(wrapper.find('a[href="/prompts"]').text()).toContain('All prompts');
|
|||
|
|
expect(wrapper.find('a[href="/prompts/7/edit"]').text()).toContain('Edit');
|
|||
|
|
expect(wrapper.find('a[href="/prompts?tag=chat"]').text()).toBe('chat');
|
|||
|
|
expect(wrapper.text()).toContain('4×');
|
|||
|
|
expect(wrapper.find('pre').text()).toBe('Hi {{ name }}!');
|
|||
|
|
expect(wrapper.text()).toMatch(/Oct\s+1,?\s+2026/);
|
|||
|
|
expect(wrapper.text()).toMatch(/Sep\s+1,?\s+2026/);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('renders the star as Favorite and the last-used date as an em dash when unset', () => {
|
|||
|
|
const wrapper = mount(Show, {
|
|||
|
|
props: baseProps({ is_favorite: false, description: null, last_used_at: null }),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(wrapper.find('button[aria-label="Favorite"]').exists()).toBe(true);
|
|||
|
|
expect(wrapper.text()).not.toContain('Says hi.');
|
|||
|
|
expect(wrapper.text()).toContain('Last used:');
|
|||
|
|
expect(wrapper.text()).toContain('—');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('toggles favorite via router.post', async () => {
|
|||
|
|
const wrapper = mount(Show, { props: baseProps() });
|
|||
|
|
|
|||
|
|
await wrapper.find('button[aria-label="Unfavorite"]').trigger('click');
|
|||
|
|
|
|||
|
|
const call = last();
|
|||
|
|
expect(call.method).toBe('post');
|
|||
|
|
expect(call.url).toBe('/prompts/7/toggle-favorite');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('duplicates via router.post', async () => {
|
|||
|
|
const wrapper = mount(Show, { props: baseProps() });
|
|||
|
|
|
|||
|
|
await buttonWithText(wrapper, 'Duplicate').trigger('click');
|
|||
|
|
|
|||
|
|
const call = last();
|
|||
|
|
expect(call.method).toBe('post');
|
|||
|
|
expect(call.url).toBe('/prompts/7/duplicate');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('deletes after confirmation', async () => {
|
|||
|
|
const wrapper = mount(Show, { props: baseProps() });
|
|||
|
|
|
|||
|
|
await buttonWithText(wrapper, 'Delete').trigger('click');
|
|||
|
|
|
|||
|
|
const dialog = wrapper.find('[role="dialog"]');
|
|||
|
|
expect(dialog.text()).toContain('will be permanently deleted');
|
|||
|
|
|
|||
|
|
await dialog.findAll('button').find((b) => b.text() === 'Delete').trigger('click');
|
|||
|
|
|
|||
|
|
const call = last();
|
|||
|
|
expect(call.method).toBe('delete');
|
|||
|
|
expect(call.url).toBe('/prompts/7');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('cancels the delete dialog without deleting', async () => {
|
|||
|
|
const wrapper = mount(Show, { props: baseProps() });
|
|||
|
|
|
|||
|
|
await buttonWithText(wrapper, 'Delete').trigger('click');
|
|||
|
|
await wrapper
|
|||
|
|
.find('[role="dialog"]')
|
|||
|
|
.findAll('button')
|
|||
|
|
.find((b) => b.text() === 'Cancel')
|
|||
|
|
.trigger('click');
|
|||
|
|
|
|||
|
|
expect(wrapper.find('[role="dialog"]').exists()).toBe(false);
|
|||
|
|
expect(inertiaMock.routerCalls.filter((c) => c.method === 'delete')).toHaveLength(0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('copies the template body and shows a transient confirmation', async () => {
|
|||
|
|
const writeText = installClipboard();
|
|||
|
|
const wrapper = mount(Show, { props: baseProps() });
|
|||
|
|
const copyButton = buttonWithText(wrapper, 'Copy template');
|
|||
|
|
|
|||
|
|
await copyButton.trigger('click');
|
|||
|
|
await flushPromises();
|
|||
|
|
|
|||
|
|
expect(writeText).toHaveBeenCalledWith('Hi {{ name }}!');
|
|||
|
|
expect(copyButton.text()).toBe('✓ Copied');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('ignores clipboard failures when copying the template', async () => {
|
|||
|
|
installClipboard(true);
|
|||
|
|
const wrapper = mount(Show, { props: baseProps() });
|
|||
|
|
const copyButton = buttonWithText(wrapper, 'Copy template');
|
|||
|
|
|
|||
|
|
await copyButton.trigger('click');
|
|||
|
|
await flushPromises();
|
|||
|
|
|
|||
|
|
expect(copyButton.text()).toBe('Copy template');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('renders the prompt via /use on success and reloads', async () => {
|
|||
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|||
|
|
ok: true,
|
|||
|
|
json: async () => ({ rendered: 'Hi Ada!' }),
|
|||
|
|
});
|
|||
|
|
vi.stubGlobal('fetch', fetchMock);
|
|||
|
|
|
|||
|
|
const wrapper = mount(Show, { props: baseProps() });
|
|||
|
|
await wrapper.findAll('textarea')[0].setValue('Ada');
|
|||
|
|
await buttonWithText(wrapper, 'Use prompt (counts usage)').trigger('click');
|
|||
|
|
await flushPromises();
|
|||
|
|
|
|||
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|||
|
|
'/prompts/7/use',
|
|||
|
|
expect.objectContaining({
|
|||
|
|
method: 'POST',
|
|||
|
|
body: JSON.stringify({ values: { name: 'Ada' } }),
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
expect(wrapper.findAll('pre').at(-1).text()).toBe('Hi Ada!');
|
|||
|
|
expect(inertiaMock.routerCalls.some((c) => c.method === 'reload')).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('shows server error details when /use fails with missing variables', async () => {
|
|||
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|||
|
|
ok: false,
|
|||
|
|
json: async () => ({ message: 'Missing variables', missing: ['name'] }),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
const wrapper = mount(Show, { props: baseProps() });
|
|||
|
|
await buttonWithText(wrapper, 'Use prompt (counts usage)').trigger('click');
|
|||
|
|
await flushPromises();
|
|||
|
|
|
|||
|
|
expect(wrapper.text()).toContain('Missing variables');
|
|||
|
|
expect(wrapper.text()).toContain('(name)');
|
|||
|
|
expect(inertiaMock.routerCalls.some((c) => c.method === 'reload')).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('falls back to a generic message when the error body is unreadable', async () => {
|
|||
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|||
|
|
ok: false,
|
|||
|
|
json: async () => {
|
|||
|
|
throw new Error('not json');
|
|||
|
|
},
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
const wrapper = mount(Show, { props: baseProps() });
|
|||
|
|
await buttonWithText(wrapper, 'Use prompt (counts usage)').trigger('click');
|
|||
|
|
await flushPromises();
|
|||
|
|
|
|||
|
|
expect(wrapper.text()).toContain('Could not render the prompt.');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('shows a generic message when the request fails', async () => {
|
|||
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
|
|||
|
|
|
|||
|
|
const wrapper = mount(Show, { props: baseProps() });
|
|||
|
|
await buttonWithText(wrapper, 'Use prompt (counts usage)').trigger('click');
|
|||
|
|
await flushPromises();
|
|||
|
|
|
|||
|
|
expect(wrapper.text()).toContain('Request failed.');
|
|||
|
|
});
|
|||
|
|
});
|