fix: sanitize API error text display and add time_blockers tests

Add extractErrorMessage helper for JSON error body parsing and apply sanitizeText across all toast displays. Add time_blockers test coverage for new holiday placeholder cleanup and overlapping scenarios.
This commit is contained in:
2026-08-22 00:34:48 +01:00
parent 388ac5948a
commit 3eec71a56c
30 changed files with 283 additions and 71 deletions
@@ -3,6 +3,7 @@
import { SvelteDate, SvelteMap } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import * as Textarea from '$lib/components/ui/textarea';
@@ -724,7 +725,7 @@
onSubmitted();
} else {
const text = await response.text();
toast.error(text || 'Failed to submit edit/reschedule request');
toast.error(extractErrorMessage(text) || 'Failed to submit edit/reschedule request');
}
} catch {
toast.error('Network error');
@@ -6,6 +6,7 @@
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -231,7 +232,7 @@
pendingEditRequest = editData.edit_request || null;
} else {
const text = await bookingResp.text();
toast.error('Failed to load booking: ' + text);
toast.error('Failed to load booking: ' + extractErrorMessage(text));
open = false;
}
} catch (err) {
@@ -361,7 +362,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
open = false;
} else {
const text = await response.text();
toast.error('Failed to cancel: ' + text);
toast.error('Failed to cancel: ' + extractErrorMessage(text));
}
} catch {
toast.error('Network error');
@@ -2,7 +2,7 @@
import { SvelteDate } from 'svelte/reactivity';
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { formatDateTime, formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
@@ -335,7 +335,7 @@
onApproved();
} else {
const text = await response.text();
toast.error('Failed to confirm: ' + sanitizeText(text), { id: loadingToast });
toast.error('Failed to confirm: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
}
} catch {
toast.error('Network error confirming booking', { id: loadingToast });
@@ -363,7 +363,7 @@
onApproved();
} else {
const text = await response.text();
toast.error('Failed to decline: ' + sanitizeText(text), { id: loadingToast });
toast.error('Failed to decline: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
}
} catch {
toast.error('Network error declining booking', { id: loadingToast });
@@ -1,6 +1,7 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteDate, SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
@@ -718,7 +719,7 @@
return false;
} else {
const errorText = await response.text();
toast.error(`Failed to reserve slot: ${errorText}`);
toast.error(`Failed to reserve slot: ${extractErrorMessage(errorText)}`);
return false;
}
} catch {
@@ -855,7 +856,7 @@
toast.success('Custom service created and added');
} else {
const err = await response.text();
toast.error(`Failed: ${err}`);
toast.error(`Failed: ${extractErrorMessage(err)}`);
}
} catch {
toast.error('Network error');
@@ -1014,7 +1015,7 @@
onBookingCreated?.();
} else {
const errorText = await res.text();
toast.error(`Failed to create booking: ${errorText}`);
toast.error(`Failed to create booking: ${extractErrorMessage(errorText)}`);
}
} catch {
toast.error('An error occurred while creating booking');
@@ -2,6 +2,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteDate } from 'svelte/reactivity';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
@@ -84,7 +85,7 @@
fetchBookingDetails();
} else {
const text = await response.text();
toast.error('Failed to cancel: ' + text);
toast.error('Failed to cancel: ' + extractErrorMessage(text));
}
} catch {
toast.error('Network error');
@@ -210,7 +211,7 @@
};
} else {
const text = await response.text();
toast.error('Failed to load booking details: ' + text);
toast.error('Failed to load booking details: ' + extractErrorMessage(text));
}
} catch (_err) {
console.error('Network error loading booking details:', _err);
@@ -2,6 +2,7 @@
import { apiFetch } from '$lib/utils/api';
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
@@ -77,7 +78,7 @@
}
} else {
const text = await response.text();
toast.error('Failed to load bookings: ' + text);
toast.error('Failed to load bookings: ' + extractErrorMessage(text));
}
} catch (err) {
console.error('Error fetching bookings:', err);
@@ -2,6 +2,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
@@ -257,7 +258,7 @@
showEditModal = false;
} else {
const errText = await res.text();
toast.error(errText || 'Failed to update settings');
toast.error(extractErrorMessage(errText) || 'Failed to update settings');
}
} catch {
toast.error('Network error saving settings');
@@ -1,7 +1,7 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { range } from '$lib/utils/format';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
@@ -156,7 +156,7 @@
await fetchServices();
} else {
const err = await response.text();
toast.error('Failed: ' + sanitizeText(err));
toast.error('Failed: ' + sanitizeText(extractErrorMessage(err)));
}
} catch {
toast.error('Network error');
@@ -184,7 +184,7 @@
toast.error('A service with this name already exists');
} else {
const errText = await response.text();
toast.error('Failed: ' + sanitizeText(errText));
toast.error('Failed: ' + sanitizeText(extractErrorMessage(errText)));
}
} catch {
toast.error('Network error');
@@ -204,7 +204,7 @@
toast.error('Cannot delete: used in bookings. Promote first.');
} else {
const errText = await response.text();
toast.error('Failed: ' + sanitizeText(errText));
toast.error('Failed: ' + sanitizeText(extractErrorMessage(errText)));
}
} catch {
toast.error('Network error');
@@ -3,6 +3,7 @@
import { apiFetch } from '$lib/utils/api';
import { range } from '$lib/utils/format';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
@@ -186,7 +187,7 @@
await loadCampaigns();
} else {
const text = await res.text();
toast.error(text || 'Failed to save campaign');
toast.error(extractErrorMessage(text) || 'Failed to save campaign');
}
} catch {
toast.error('Failed to save campaign');
@@ -2,6 +2,7 @@
import { apiFetch } from '$lib/utils/api';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
@@ -138,7 +139,7 @@
notes = booking.notes || '';
} else {
const text = await response.text();
toast.error('Failed to load booking: ' + text);
toast.error('Failed to load booking: ' + extractErrorMessage(text));
}
} catch (err) {
console.error('Error fetching booking:', err);
@@ -331,7 +332,7 @@
onSaved?.();
} else {
const text = await response.text();
toast.error('Failed to update booking: ' + text);
toast.error('Failed to update booking: ' + extractErrorMessage(text));
}
} catch (err) {
console.error('Error saving booking:', err);
@@ -379,7 +380,7 @@
fetchBooking();
} else {
const text = await response.text();
toast.error('Failed to process refund: ' + text);
toast.error('Failed to process refund: ' + extractErrorMessage(text));
}
} catch (err) {
console.error('Error processing refund:', err);
@@ -1,6 +1,7 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
@@ -128,7 +129,7 @@
onApproved();
} else {
const text = await response.text();
toast.error('Failed to approve: ' + text, { id: loadingToast });
toast.error('Failed to approve: ' + extractErrorMessage(text), { id: loadingToast });
}
} catch (err) {
console.error('Error approving edit request:', err);
@@ -160,7 +161,7 @@
onDenied();
} else {
const text = await response.text();
toast.error('Failed to deny: ' + text, { id: loadingToast });
toast.error('Failed to deny: ' + extractErrorMessage(text), { id: loadingToast });
}
} catch (err) {
console.error('Error denying edit request:', err);
@@ -2,6 +2,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
@@ -397,7 +398,7 @@
await fetchGiftCards();
} else {
const errText = await res.text();
toast.error(errText || 'Failed to create inventory card');
toast.error(extractErrorMessage(errText) || 'Failed to create inventory card');
}
} catch {
toast.error('Network error');
@@ -439,7 +440,7 @@
await fetchGiftCards();
} else {
const errText = await res.text();
toast.error(errText || 'Failed to transfer balance');
toast.error(extractErrorMessage(errText) || 'Failed to transfer balance');
}
} catch {
toast.error('Network error transferring balance');
@@ -1,5 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { apiFetch } from '$lib/utils/api';
@@ -41,7 +42,7 @@
}
} else {
const text = await response.text();
toast.error('Failed to load services: ' + text);
toast.error('Failed to load services: ' + extractErrorMessage(text));
}
} catch (err) {
console.error('Error fetching eligible services:', err);
@@ -77,7 +78,7 @@
onPatchTestAdded();
} else {
const text = await response.text();
toast.error(text || 'Failed to add patch test', { id: loadingToast });
toast.error(extractErrorMessage(text) || 'Failed to add patch test', { id: loadingToast });
}
} catch (err) {
console.error('Error adding patch test:', err);
@@ -1,5 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { Button } from '$lib/components/ui/button';
import { apiFetch } from '$lib/utils/api';
import * as Card from '$lib/components/ui/card';
@@ -216,7 +217,7 @@
await fetchData();
} else {
const errText = await res.text();
toast.error(`Failed to save: ${errText}`);
toast.error(`Failed to save: ${extractErrorMessage(errText)}`);
}
} catch (err) {
console.error(err);
@@ -1,6 +1,7 @@
<script lang="ts">
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { Checkbox } from '$lib/components/ui/checkbox';
@@ -323,7 +324,7 @@
onSaved?.();
} else {
const text = await response.text();
toast.error('Failed to reschedule: ' + text, { id: loadingToast });
toast.error('Failed to reschedule: ' + extractErrorMessage(text), { id: loadingToast });
}
} catch (err) {
console.error('Error rescheduling booking:', err);
@@ -1,5 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import { range } from '$lib/utils/format';
@@ -192,7 +193,7 @@
await fetchServices();
} else {
const errorText = await response.text();
toast.error(`Failed to update service: ${errorText}`);
toast.error(`Failed to update service: ${extractErrorMessage(errorText)}`);
}
} catch (err) {
console.error('Error toggling service:', err);
@@ -219,7 +220,7 @@
await fetchServices();
} else {
const errorText = await response.text();
toast.error(`Failed to delete service: ${errorText}`);
toast.error(`Failed to delete service: ${extractErrorMessage(errorText)}`);
}
} catch (err) {
console.error('Error deleting service:', err);
@@ -275,10 +276,10 @@
toast.error('A service with this name already exists', { id: loadingToast });
} else if (response.status === 400) {
const errorText = await response.text();
toast.error(`Validation error: ${errorText}`, { id: loadingToast });
toast.error(`Validation error: ${extractErrorMessage(errorText)}`, { id: loadingToast });
} else {
const errorText = await response.text();
toast.error(`Failed to create service: ${errorText}`, { id: loadingToast });
toast.error(`Failed to create service: ${extractErrorMessage(errorText)}`, { id: loadingToast });
}
} catch (err) {
console.error('Error creating service:', err);
@@ -1,6 +1,7 @@
<script lang="ts">
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
@@ -115,7 +116,7 @@
selectedUser = await response.json();
} else {
const text = await response.text();
toast.error('Failed to load user details: ' + text);
toast.error('Failed to load user details: ' + extractErrorMessage(text));
}
} catch (err) {
console.error('Error fetching user details:', err);
@@ -191,7 +192,7 @@
}
} else {
const text = await response.text();
toast.error('Failed to load user bookings: ' + text);
toast.error('Failed to load user bookings: ' + extractErrorMessage(text));
}
} catch (err) {
console.error('Error fetching user bookings:', err);
@@ -1,5 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import { range } from '$lib/utils/format';
import * as Card from '$lib/components/ui/card';
@@ -94,7 +95,7 @@
}
} else {
const text = await response.text();
toast.error('Failed to load users: ' + text);
toast.error('Failed to load users: ' + extractErrorMessage(text));
}
} catch (err) {
console.error('Error fetching users:', err);
@@ -6,6 +6,7 @@
import { SvelteDate } from 'svelte/reactivity';
import { onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import type { AvailableHoursDay, Service } from '$lib/types/booking';
import {
@@ -306,7 +307,7 @@
return false;
} else {
const errorText = await response.text();
toast.error(`Failed to reserve slot: ${errorText}`);
toast.error(`Failed to reserve slot: ${extractErrorMessage(errorText)}`);
return false;
}
} catch (_err) {
@@ -2,6 +2,7 @@
import { generateUUID } from '$lib/utils/uuid';
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay';
@@ -409,7 +410,7 @@
toast.success('Custom service created and added');
} else {
const err = await response.text();
toast.error(`Failed: ${err}`);
toast.error(`Failed: ${extractErrorMessage(err)}`);
}
} catch {
toast.error('Network error');
@@ -555,7 +556,7 @@
onBookingCreated?.();
} else {
const errorText = await res.text();
toast.error(`Failed to create booking: ${errorText}`);
toast.error(`Failed to create booking: ${extractErrorMessage(errorText)}`);
}
} catch {
toast.error('An error occurred while creating booking');
@@ -1,5 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { browser } from '$app/environment';
import { apiFetch } from '$lib/utils/api';
import { range } from '$lib/utils/format';
@@ -128,7 +129,7 @@
);
} else {
const text = await response.text();
error = 'Failed to load working hours: ' + text;
error = 'Failed to load working hours: ' + extractErrorMessage(text);
console.error('Error fetching default hours:', text);
}
} catch (err) {
@@ -185,7 +186,7 @@
toast.error('Unauthorized. Please log in again.', { id: loadingToast });
} else {
const text = await response.text();
toast.error('Failed to save: ' + text, { id: loadingToast });
toast.error('Failed to save: ' + extractErrorMessage(text), { id: loadingToast });
}
} catch (err) {
console.error('save default hours', err);
@@ -25,6 +25,7 @@
import { apiFetch, getAuthHeaders } from '$lib/utils/api';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { onDestroy } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
@@ -1472,17 +1473,10 @@
}, 50);
} else {
const errorText = await response.text();
let errorMessage = errorText.trim();
let errorMessage = extractErrorMessage(errorText);
if (!errorMessage) {
errorMessage = 'Failed to submit booking. Please try again.';
}
// Backend may return plain text or JSON
try {
const errorData = JSON.parse(errorText);
if (errorData.error) errorMessage = errorData.error;
} catch {
/* use raw text from backend */
}
if (response.status === 409) {
if (errorMessage.includes('active booking') || errorMessage.includes('already have')) {
@@ -1,5 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -241,7 +242,7 @@
});
if (!res.ok) {
const errData = await res.text();
throw new Error(errData || 'Failed to apply loyalty discount');
throw new Error(extractErrorMessage(errData) || 'Failed to apply loyalty discount');
}
}
@@ -271,7 +272,7 @@
if (!response.ok) {
const errData = await response.text();
throw new Error(errData || 'Failed to initiate payment');
throw new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
}
const data = await response.json();
@@ -412,7 +413,7 @@
if (!response.ok) {
const errData = await response.text();
throw new Error(errData || 'Failed to process payment');
throw new Error(extractErrorMessage(errData) || 'Failed to process payment');
}
const data = await response.json();
@@ -539,7 +540,7 @@
if (!response.ok) {
const errData = await response.text();
throw new Error(errData || 'Failed to process gift card');
throw new Error(extractErrorMessage(errData) || 'Failed to process gift card');
}
const data = await response.json();
@@ -614,7 +615,7 @@
if (!response.ok) {
const errData = await response.text();
throw new Error(errData || 'Failed to process saved card payment');
throw new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
}
const data = await response.json();
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -407,7 +408,7 @@
});
if (!redemptionResponse.ok) {
const errData = await redemptionResponse.text();
throw new Error(errData || 'Failed to apply loyalty discount');
throw new Error(extractErrorMessage(errData) || 'Failed to apply loyalty discount');
}
} catch (_err) {
status = 'error';
@@ -452,7 +453,7 @@
if (!response.ok) {
const errData = await response.text();
throw new Error(errData || 'Failed to initiate payment');
throw new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
}
const data = await response.json();
@@ -3,7 +3,7 @@
import { SvelteDate } from 'svelte/reactivity';
import { CalendarDate } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { formatUserName } from '$lib/utils/nameDisplay';
import { range } from '$lib/utils/format';
import * as Card from '$lib/components/ui/card';
@@ -523,7 +523,7 @@
await fetchTodayBlockersData();
} else {
const text = await response.text();
toast.error('Failed to create: ' + sanitizeText(text), { id: loadingToast });
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
}
} catch (err) {
console.error('Error creating time blocker:', err);
@@ -547,7 +547,7 @@
await fetchTodayBlockersData();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + sanitizeText(text), { id: loadingToast });
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
}
} catch (err) {
console.error('Error deleting time blocker:', err);