Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core.ref
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ca5674382cb750b9d226b70067496f548f4937a0
ea4ea1d83f8c5f61da91bc7c72c8cde0018cd569
17 changes: 11 additions & 6 deletions frontend/src/lib/api/core.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,11 @@ describe('fetchAPI — error mapping', () => {
status: 400,
statusText: 'Bad Request',
body: JSON.stringify({
error: { code: 'invalid_request', message: 'page is out of range', details: { field: 'page' } },
error: {
code: 'invalid_request',
message: 'page is out of range',
details: { field: 'page' },
},
request_id: 'req-v2',
}),
})
Expand Down Expand Up @@ -467,7 +471,7 @@ describe('fetchAPI — network and timeout errors', () => {
await expect(request).rejects.toMatchObject({ name: 'AbortError' });
});

test('treats a hidden-document TypeError as navigation cancellation', async () => {
test('preserves network errors in a hidden live document', async () => {
const originalVisibilityState = document.visibilityState;
Object.defineProperty(document, 'visibilityState', {
configurable: true,
Expand All @@ -476,7 +480,10 @@ describe('fetchAPI — network and timeout errors', () => {
global.fetch = vi.fn(() => Promise.reject(new TypeError('Failed to fetch')));

try {
await expect(fetchAPI('/items')).rejects.toMatchObject({ name: 'AbortError' });
await expect(fetchAPI('/items')).rejects.toMatchObject({
code: 'NETWORK_ERROR',
status: 0,
});
} finally {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
Expand Down Expand Up @@ -555,9 +562,7 @@ describe('fetchAPI — administration UI refresh signaling', () => {
window.history.replaceState({}, '', '/');
}

expect(events).toEqual([
{ endpoint: '/admin/llm-connections', method: 'POST' },
]);
expect(events).toEqual([{ endpoint: '/admin/llm-connections', method: 'POST' }]);
});

test('supports admin routes below a configured context path', async () => {
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/lib/api/zammadObservability.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { zammadTickets } from './integrations.js';

afterEach(() => vi.unstubAllGlobals());

describe('Zammad observation API', () => {
it.each([
['item timeline', () => zammadTickets.history(99, { limit: 6 }), '/api/items/99/zammad-history?limit=6'],
['workspace overview', () => zammadTickets.workspaceOverview(23, { limit: 5 }), '/api/workspaces/23/zammad-overview?limit=5'],
])('loads the %s endpoint', async (_label, request, path) => {
const response = { recent_changes: [] };
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(response), {
status: 200, headers: { 'content-type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);

await expect(request()).resolves.toEqual(response);
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0][0]).toBe(path);
});
});
4 changes: 2 additions & 2 deletions frontend/src/lib/composables/useItemEventStream.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ describe('normalizeItemEventStreamID', () => {
});

describe('createConnectionReconcileTracker', () => {
it('does not reconcile the initial healthy connection', () => {
it('reconciles the initial connection to cover changes before subscription', () => {
const tracker = createConnectionReconcileTracker();

expect(tracker.markConnected()).toBe(false);
expect(tracker.markConnected()).toBe(true);
});

it('reconciles after a connected stream disconnects', () => {
Expand Down
156 changes: 156 additions & 0 deletions frontend/src/lib/features/items/ZammadItemPanel.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/** @vitest-environment jsdom */

import '@testing-library/jest-dom/vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
import { tick } from 'svelte';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ZammadItemPanel from './ZammadItemPanel.svelte';

const mocks = vi.hoisted(() => ({
connections: vi.fn(),
links: vi.fn(),
history: vi.fn(),
refresh: vi.fn(),
successToast: vi.fn(),
errorToast: vi.fn(),
}));
vi.mock('../../api.js', () => ({
api: {
zammadConnections: { forWorkspace: mocks.connections },
zammadTickets: { forItem: mocks.links, history: mocks.history, refresh: mocks.refresh },
},
}));
vi.mock('../../stores', () => ({ authStore: { currentUser: { timezone: 'UTC' } } }));
vi.mock('../../stores/toasts.svelte.js', () => ({
successToast: mocks.successToast,
errorToast: mocks.errorToast,
}));
vi.mock('../../stores/i18n.svelte.js', () => ({
t: (key, params = {}) => {
if (key === 'zammad.ticketNumber') return `Zammad #${params.number}`;
if (key === 'zammad.overview.statusIdFallback') return `Status ID ${params.id}`;
if (key === 'zammad.timeline.change') return `${params.field}: ${params.from} → ${params.to}`;
return key;
},
}));

const ticket = (overrides = {}) => ({
id: 'link-1',
connection_id: 'dev',
connection_name: 'Helpdesk',
ticket_id: 42,
ticket_number: '123',
ticket_title: 'VPN access',
ticket_url: 'https://zammad.example.test/#ticket/zoom/42',
group_name: 'Network',
owner_name: 'Ada',
sync_state: 'linked',
last_status_id: 4,
last_status_name: 'closed',
closed: true,
...overrides,
});

beforeEach(() => {
vi.clearAllMocks();
mocks.connections.mockResolvedValue([
{ id: 'dev', name: 'Helpdesk', auth_method: 'api_token', ready: true },
]);
mocks.links.mockResolvedValue([ticket()]);
mocks.history.mockResolvedValue({ events: [] });
mocks.refresh.mockResolvedValue(ticket());
});
afterEach(cleanup);

describe('Zammad item panel observations', () => {
it('keeps the current sidebar actions while showing ticket title, status and observed changes', async () => {
mocks.history.mockResolvedValue({
events: [
{
id: 'change-1',
field: 'owner',
ticket_number: '123',
old_value: { id: 1, name: 'Unassigned' },
new_value: { id: 9, name: 'Ada' },
observed_at: '2026-09-30T10:00:00Z',
},
{ id: 'ignored', field: 'priority' },
],
});
render(ZammadItemPanel, { itemId: 5, workspaceId: 7, canEdit: true });

const card = await screen.findByTestId('zammad-ticket-card-link-1');
expect(within(card).getByRole('link', { name: 'VPN access' })).toHaveAttribute(
'href',
'https://zammad.example.test/#ticket/zoom/42'
);
expect(within(card).getByText('closed')).toBeInTheDocument();
expect(within(card).getByRole('button', { name: 'zammad.refreshTicket' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'zammad.createTicket' })).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'zammad.timeline.toggle' }));
expect(
await screen.findByText('zammad.timeline.field.owner: zammad.unassignedOwner → Ada')
).toBeInTheDocument();
expect(mocks.history).toHaveBeenCalledWith(5, { limit: 6 });
expect(screen.queryByText('priority')).not.toBeInTheDocument();
});

it('reloads hidden history after a link change invalidates an in-flight response', async () => {
let resolveOldHistory;
const oldHistory = new Promise((resolve) => {
resolveOldHistory = resolve;
});
mocks.history.mockReturnValueOnce(oldHistory).mockResolvedValueOnce({
events: [
{
id: 'fresh-change',
field: 'status',
ticket_number: '123',
old_value: { id: 2, name: 'open' },
new_value: { id: 4, name: 'freshly closed' },
observed_at: '2026-10-04T10:00:00Z',
},
],
});
render(ZammadItemPanel, { itemId: 5, workspaceId: 7 });
await screen.findByTestId('zammad-ticket-card-link-1');
await fireEvent.click(screen.getByRole('button', { name: 'zammad.timeline.toggle' }));
expect(mocks.history).toHaveBeenCalledTimes(1);
await fireEvent.click(screen.getByRole('button', { name: 'zammad.timeline.toggle' }));
window.dispatchEvent(new CustomEvent('item-zammad-links-changed', { detail: { itemId: 5 } }));
await waitFor(() => expect(mocks.links).toHaveBeenCalledTimes(2));
await screen.findByTestId('zammad-ticket-card-link-1');
resolveOldHistory({ events: [] });
await oldHistory;
await tick();
await fireEvent.click(screen.getByRole('button', { name: 'zammad.timeline.toggle' }));
await waitFor(() => expect(mocks.history).toHaveBeenCalledTimes(2));
expect(
await screen.findByText('zammad.timeline.field.status: open → freshly closed')
).toBeInTheDocument();
});

it('shows a nameless observed status by ID', async () => {
mocks.links.mockResolvedValue([
ticket({ last_status_id: 9, last_status_name: '', closed: false }),
]);
render(ZammadItemPanel, { itemId: 5, workspaceId: 7 });
const card = await screen.findByTestId('zammad-ticket-card-link-1');
expect(within(card).getByText('Status ID 9')).toBeInTheDocument();
});

it('retains the sidebar card if reloading after a successful refresh fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
mocks.links.mockResolvedValueOnce([ticket()]).mockRejectedValueOnce(new Error('reload failed'));
render(ZammadItemPanel, { itemId: 5, workspaceId: 7, canEdit: true });
const card = await screen.findByTestId('zammad-ticket-card-link-1');
await fireEvent.click(within(card).getByRole('button', { name: 'zammad.refreshTicket' }));
await waitFor(() =>
expect(mocks.errorToast).toHaveBeenCalledWith('zammad.ticketReloadAfterChangeFailed')
);
expect(screen.getByTestId('zammad-ticket-card-link-1')).toBeInTheDocument();
expect(screen.queryByText('zammad.loadLinksFailed')).not.toBeInTheDocument();
expect(mocks.successToast).not.toHaveBeenCalled();
consoleError.mockRestore();
});
});
30 changes: 28 additions & 2 deletions frontend/src/lib/features/support/CannedResponsePicker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,37 @@ describe("CannedResponsePicker", () => {
});
});

it("loads nothing and shows no options without a workspace", async () => {
it("loads nothing and renders no picker without a workspace", async () => {
render(CannedResponsePicker, { props: { workspaceId: null, isOpen: true, onSelect: vi.fn() } });
await waitFor(() => {
expect(mocks.getAll).not.toHaveBeenCalled();
});
expect(screen.getByText("common.loading")).toBeTruthy();
expect(screen.queryByTestId("canned-response-picker")).toBeNull();
});

it("renders no picker when the workspace has no canned responses", async () => {
mocks.getAll.mockResolvedValue([]);
render(CannedResponsePicker, { props: { workspaceId: 5, isOpen: true, onSelect: vi.fn() } });

await waitFor(() => {
expect(mocks.getAll).toHaveBeenCalledWith(5);
});
expect(screen.queryByTestId("canned-response-picker")).toBeNull();
expect(screen.queryByTestId("canned-response-search")).toBeNull();
});

it("does not render the picker until responses load", async () => {
let resolveLoad;
mocks.getAll.mockReturnValue(
new Promise((resolve) => {
resolveLoad = resolve;
})
);
render(CannedResponsePicker, { props: { workspaceId: 5, isOpen: true, onSelect: vi.fn() } });

expect(screen.queryByTestId("canned-response-picker")).toBeNull();

resolveLoad(responses);
expect(await screen.findByTestId("canned-response-picker")).toBeTruthy();
});
});
Loading
Loading