Archived
Merge feature/admin-ui-wins
Admin for the menu and enquiries, gallery swipe/keyboard, and the N+1 fix on product photos.
This commit is contained in:
@@ -7,6 +7,9 @@ import ProductsPage from '@/pages/Products';
|
|||||||
import HistoryPage from '@/pages/History';
|
import HistoryPage from '@/pages/History';
|
||||||
import ContactPage from '@/pages/Contact';
|
import ContactPage from '@/pages/Contact';
|
||||||
import NotFoundPage from '@/pages/NotFound';
|
import NotFoundPage from '@/pages/NotFound';
|
||||||
|
import AdminPage from '@/pages/admin/AdminPage';
|
||||||
|
import ProductEditorPage from '@/pages/admin/ProductEditorPage';
|
||||||
|
import { AuthProvider } from '@/lib/auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-side navigation keeps the previous scroll position, which lands you halfway down a page you
|
* Client-side navigation keeps the previous scroll position, which lands you halfway down a page you
|
||||||
@@ -23,6 +26,7 @@ const ScrollToTop = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
|
<AuthProvider>
|
||||||
<div className="min-h-screen bg-bakery-50 flex flex-col">
|
<div className="min-h-screen bg-bakery-50 flex flex-col">
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Header />
|
<Header />
|
||||||
@@ -32,11 +36,15 @@ const App = () => (
|
|||||||
<Route path="/products" element={<ProductsPage />} />
|
<Route path="/products" element={<ProductsPage />} />
|
||||||
<Route path="/history" element={<HistoryPage />} />
|
<Route path="/history" element={<HistoryPage />} />
|
||||||
<Route path="/contact" element={<ContactPage />} />
|
<Route path="/contact" element={<ContactPage />} />
|
||||||
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
<Route path="/admin/products/new" element={<ProductEditorPage />} />
|
||||||
|
<Route path="/admin/products/:id" element={<ProductEditorPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
interface ProductGalleryProps {
|
interface ProductGalleryProps {
|
||||||
images: string[];
|
images: string[];
|
||||||
@@ -11,16 +11,51 @@ interface ProductGalleryProps {
|
|||||||
* Replaces react-awesome-slider, which hasn't been published since 2020 and pins peer deps to
|
* Replaces react-awesome-slider, which hasn't been published since 2020 and pins peer deps to
|
||||||
* React 16 — the same job in a fraction of the code, and one less unmaintained dependency in a
|
* React 16 — the same job in a fraction of the code, and one less unmaintained dependency in a
|
||||||
* build we gate on CVEs. Behaviour is what the old cards did: one image at a time, square crop,
|
* build we gate on CVEs. Behaviour is what the old cards did: one image at a time, square crop,
|
||||||
* arrows only when they'd do something.
|
* arrows only when they'd do something — plus swipe and keyboard, which the old slider had on touch
|
||||||
|
* devices and the first version of this did not.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Past this many pixels a horizontal drag counts as a swipe rather than a tap or a page scroll. */
|
||||||
|
const SWIPE_THRESHOLD = 40;
|
||||||
|
|
||||||
const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
|
const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const many = images.length > 1;
|
const many = images.length > 1;
|
||||||
|
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length);
|
const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length);
|
||||||
|
|
||||||
|
const onTouchStart = (e: React.TouchEvent) => {
|
||||||
|
const t = e.touches[0];
|
||||||
|
touchStart.current = { x: t.clientX, y: t.clientY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = (e: React.TouchEvent) => {
|
||||||
|
const start = touchStart.current;
|
||||||
|
touchStart.current = null;
|
||||||
|
if (!start || !many) return;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
const dx = t.clientX - start.x;
|
||||||
|
const dy = t.clientY - start.y;
|
||||||
|
// Ignore anything more vertical than horizontal — that is the page being scrolled, not a swipe.
|
||||||
|
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) <= Math.abs(dy)) return;
|
||||||
|
step(dx < 0 ? 1 : -1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative aspect-square bg-bakery-100">
|
<div
|
||||||
|
className="relative aspect-square bg-bakery-100"
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
onKeyDown={many ? (e) => {
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
|
||||||
|
if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
|
||||||
|
} : undefined}
|
||||||
|
tabIndex={many ? 0 : undefined}
|
||||||
|
role={many ? 'group' : undefined}
|
||||||
|
aria-roledescription={many ? 'carousel' : undefined}
|
||||||
|
aria-label={many ? `${alt} — ${images.length} photos` : undefined}
|
||||||
|
>
|
||||||
{images.map((src, i) => (
|
{images.map((src, i) => (
|
||||||
<img
|
<img
|
||||||
key={src}
|
key={src}
|
||||||
@@ -54,6 +89,18 @@ const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
|
|||||||
>
|
>
|
||||||
<span aria-hidden="true">{'>'}</span>
|
<span aria-hidden="true">{'>'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/* Which of how many. The old slider ran with bullets off, but once a card can be swiped
|
||||||
|
there is otherwise nothing to say it holds more than one photo. */}
|
||||||
|
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1.5" aria-hidden="true">
|
||||||
|
{images.map((src, i) => (
|
||||||
|
<span
|
||||||
|
key={src}
|
||||||
|
className={`h-1.5 w-1.5 rounded-full transition ${
|
||||||
|
i === index ? 'bg-white' : 'bg-white/40'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ export interface Product {
|
|||||||
images: string[];
|
images: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Security protects every mutating request with a CSRF token, and the platform's security
|
||||||
|
* starter writes it to a readable XSRF-TOKEN cookie. Without this header a POST is rejected 403 —
|
||||||
|
* including the public contact form, which is not obvious until the form stops working.
|
||||||
|
*/
|
||||||
|
function csrfHeaders(): Record<string, string> {
|
||||||
|
const token = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.split('=')[1];
|
||||||
|
return token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {};
|
||||||
|
}
|
||||||
|
|
||||||
async function get<T>(path: string): Promise<T> {
|
async function get<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(path, { headers: { Accept: 'application/json' } });
|
const res = await fetch(path, { headers: { Accept: 'application/json' } });
|
||||||
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
|
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
|
||||||
@@ -21,3 +31,83 @@ export const fetchProducts = (category?: string) =>
|
|||||||
get<Product[]>(category && category !== 'All' ? `/api/products?category=${encodeURIComponent(category)}` : '/api/products');
|
get<Product[]>(category && category !== 'All' ? `/api/products?category=${encodeURIComponent(category)}` : '/api/products');
|
||||||
|
|
||||||
export const fetchCategories = () => get<string[]>('/api/categories');
|
export const fetchCategories = () => get<string[]>('/api/categories');
|
||||||
|
|
||||||
|
// ---- who is signed in (public: the SPA asks on every page load) ----
|
||||||
|
export interface Me { authenticated: boolean; admin: boolean; name: string | null }
|
||||||
|
export const fetchMe = () => get<Me>('/api/me');
|
||||||
|
|
||||||
|
// ---- admin (everything below needs an Authentik login) ----
|
||||||
|
export interface AdminProduct {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
position: number;
|
||||||
|
imageKeys: string[];
|
||||||
|
imageUrls: string[];
|
||||||
|
}
|
||||||
|
export interface AdminEnquiry {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
delivered: boolean;
|
||||||
|
receivedAt: string;
|
||||||
|
}
|
||||||
|
export interface ProductForm {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
position: number | null;
|
||||||
|
imageKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send<T>(path: string, method: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...csrfHeaders() },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
// The backend puts a human-readable reason in `detail`; show that rather than a status code.
|
||||||
|
let detail = `${method} ${path} responded ${res.status}`;
|
||||||
|
try { detail = (await res.json()).detail ?? detail; } catch { /* not JSON */ }
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return res.status === 204 ? (undefined as T) : (res.json() as Promise<T>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchAdminProducts = () => get<AdminProduct[]>('/api/admin/products');
|
||||||
|
export const createProduct = (f: ProductForm) => send<AdminProduct>('/api/admin/products', 'POST', f);
|
||||||
|
export const updateProduct = (id: number, f: ProductForm) =>
|
||||||
|
send<AdminProduct>(`/api/admin/products/${id}`, 'PUT', f);
|
||||||
|
export const deleteProduct = (id: number) => send<void>(`/api/admin/products/${id}`, 'DELETE');
|
||||||
|
export const fetchEnquiries = () => get<AdminEnquiry[]>('/api/admin/enquiries');
|
||||||
|
|
||||||
|
export interface UploadTarget { key: string; uploadUrl: string; publicUrl: string }
|
||||||
|
|
||||||
|
/** Presign, then PUT the file straight to the bucket — the photo never passes through the app. */
|
||||||
|
export async function uploadPhoto(file: File): Promise<UploadTarget> {
|
||||||
|
const target = await send<UploadTarget>(
|
||||||
|
`/api/admin/images/presign-upload?filename=${encodeURIComponent(file.name)}`
|
||||||
|
+ `&contentType=${encodeURIComponent(file.type || 'application/octet-stream')}`,
|
||||||
|
'POST');
|
||||||
|
// Straight to the bucket, so no CSRF header here — it is a different origin and a presigned URL.
|
||||||
|
const put = await fetch(target.uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
if (!put.ok) throw new Error(`the bucket rejected the upload (${put.status})`);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The public contact form. Mutating, so it needs the CSRF token too. */
|
||||||
|
export async function submitContact(input: { name: string; email: string; message: string }) {
|
||||||
|
const res = await fetch('/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Could not send the message.');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { fetchMe, type Me } from './api';
|
||||||
|
|
||||||
|
const ANON: Me = { authenticated: false, admin: false, name: null };
|
||||||
|
|
||||||
|
const AuthContext = createContext<{ me: Me; loading: boolean }>({ me: ANON, loading: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the signed-in user from the PUBLIC /api/me.
|
||||||
|
*
|
||||||
|
* It has to be public: this runs on every page load, and if it required a login every anonymous
|
||||||
|
* visitor would be bounced to Authentik just to read the menu.
|
||||||
|
*/
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, setState] = useState<{ me: Me; loading: boolean }>({ me: ANON, loading: true });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMe()
|
||||||
|
.then((me) => setState({ me, loading: false }))
|
||||||
|
.catch(() => setState({ me: ANON, loading: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
|
|
||||||
|
/** Full-page navigation, not fetch: the OIDC handshake is a redirect chain the browser must follow. */
|
||||||
|
export const signIn = () => { window.location.href = '/oauth2/authorization/authentik'; };
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { submitContact } from '@/lib/api';
|
||||||
|
|
||||||
type Status = 'idle' | 'sending' | 'sent' | 'error';
|
type Status = 'idle' | 'sending' | 'sent' | 'error';
|
||||||
|
|
||||||
@@ -23,13 +24,7 @@ const ContactPage = () => {
|
|||||||
setStatus('sending');
|
setStatus('sending');
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/contact', {
|
await submitContact(formData);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(formData),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || 'Could not send the message.');
|
|
||||||
setStatus('sent');
|
setStatus('sent');
|
||||||
setFormData({ name: '', email: '', message: '' });
|
setFormData({ name: '', email: '', message: '' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
deleteProduct, fetchAdminProducts, fetchEnquiries,
|
||||||
|
type AdminEnquiry, type AdminProduct,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { useAuth, signIn } from '@/lib/auth';
|
||||||
|
|
||||||
|
type Tab = 'products' | 'enquiries';
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { me, loading } = useAuth();
|
||||||
|
const [tab, setTab] = useState<Tab>('products');
|
||||||
|
const [products, setProducts] = useState<AdminProduct[]>([]);
|
||||||
|
const [enquiries, setEnquiries] = useState<AdminEnquiry[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
// Only ask for admin data once we know there is a session; otherwise every anonymous visitor
|
||||||
|
// who guesses this URL gets bounced to the identity provider.
|
||||||
|
if (!me.admin) return;
|
||||||
|
fetchAdminProducts().then(setProducts).catch((e) => setError(String(e.message ?? e)));
|
||||||
|
fetchEnquiries().then(setEnquiries).catch((e) => setError(String(e.message ?? e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(load, [me.admin]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="container mx-auto px-4 py-20 text-bakery-700">Loading…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!me.admin) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-20 text-center">
|
||||||
|
<h1 className="font-adbhashitha text-3xl text-bakery-900 mb-6">Staff only</h1>
|
||||||
|
<button
|
||||||
|
onClick={signIn}
|
||||||
|
className="px-8 py-3.5 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(p: AdminProduct) {
|
||||||
|
if (!window.confirm(`Remove “${p.name}” from the menu? The photos stay in storage.`)) return;
|
||||||
|
try {
|
||||||
|
await deleteProduct(p.id);
|
||||||
|
setProducts((list) => list.filter((x) => x.id !== p.id));
|
||||||
|
} catch (e) {
|
||||||
|
setError(String((e as Error).message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-12">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-4 mb-8">
|
||||||
|
<h1 className="font-adbhashitha text-4xl text-bakery-900">Manage</h1>
|
||||||
|
<span className="text-sm text-bakery-600">signed in as {me.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-red-800">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 mb-8">
|
||||||
|
{(['products', 'enquiries'] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={`px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors ${
|
||||||
|
tab === t
|
||||||
|
? 'bg-bakery-600 text-white border-bakery-600'
|
||||||
|
: 'bg-white border-bakery-300 text-bakery-700 hover:bg-bakery-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === 'products' ? `Products (${products.length})` : `Enquiries (${enquiries.length})`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'products' ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/admin/products/new"
|
||||||
|
className="inline-block mb-6 px-6 py-2.5 bg-bakery-600 text-white rounded-full text-sm tracking-wide hover:bg-bakery-700 transition-colors"
|
||||||
|
>
|
||||||
|
Add a product
|
||||||
|
</Link>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{products.map((p) => (
|
||||||
|
<div key={p.id} className="bg-white rounded-3xl overflow-hidden shadow-xs">
|
||||||
|
{p.imageUrls[0] && (
|
||||||
|
<img src={p.imageUrls[0]} alt="" className="w-full aspect-square object-cover" />
|
||||||
|
)}
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="font-adbhashitha text-lg text-bakery-900">{p.name}</div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.15em] text-bakery-600 mb-4">
|
||||||
|
{p.category} · {p.imageKeys.length} photo{p.imageKeys.length === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 text-sm">
|
||||||
|
<Link
|
||||||
|
to={`/admin/products/${p.id}`}
|
||||||
|
className="underline underline-offset-4 text-bakery-700 hover:text-bakery-900"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => remove(p)}
|
||||||
|
className="underline underline-offset-4 text-red-700 hover:text-red-900"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{products.length === 0 && <p className="text-bakery-700">Nothing on the menu yet.</p>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{enquiries.map((e) => (
|
||||||
|
<div key={e.id} className="bg-white rounded-3xl p-6 shadow-xs">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<div className="font-medium text-bakery-900">
|
||||||
|
{e.name} <a href={`mailto:${e.email}`} className="font-normal text-bakery-600 underline underline-offset-4">{e.email}</a>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-bakery-600">
|
||||||
|
{new Date(e.receivedAt).toLocaleString()}
|
||||||
|
{!e.delivered && (
|
||||||
|
// The enquiry was saved but the relay refused it — nobody got an email.
|
||||||
|
<span className="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800">
|
||||||
|
not emailed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 whitespace-pre-wrap text-bakery-800">{e.message}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{enquiries.length === 0 && <p className="text-bakery-700">No enquiries yet.</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
createProduct, fetchAdminProducts, updateProduct, uploadPhoto,
|
||||||
|
type AdminProduct,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { useAuth, signIn } from '@/lib/auth';
|
||||||
|
|
||||||
|
export default function ProductEditorPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const editing = id !== undefined;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { me, loading } = useAuth();
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [position, setPosition] = useState<number | null>(null);
|
||||||
|
const [images, setImages] = useState<{ key: string; url: string }[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing || !me.admin) return;
|
||||||
|
fetchAdminProducts()
|
||||||
|
.then((all) => {
|
||||||
|
const p = all.find((x: AdminProduct) => String(x.id) === id);
|
||||||
|
if (!p) { setError('That product no longer exists.'); return; }
|
||||||
|
setName(p.name);
|
||||||
|
setCategory(p.category);
|
||||||
|
setPosition(p.position);
|
||||||
|
setImages(p.imageKeys.map((k, i) => ({ key: k, url: p.imageUrls[i] })));
|
||||||
|
})
|
||||||
|
.catch((e) => setError(String(e.message ?? e)));
|
||||||
|
}, [editing, id, me.admin]);
|
||||||
|
|
||||||
|
if (loading) return <p className="container mx-auto px-4 py-20 text-bakery-700">Loading…</p>;
|
||||||
|
if (!me.admin) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-20 text-center">
|
||||||
|
<button onClick={signIn} className="px-8 py-3.5 bg-bakery-600 text-white rounded-full">Sign in</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFiles(files: FileList | null) {
|
||||||
|
if (!files?.length) return;
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
// Sequentially, so the order the photos are chosen is the order they appear on the card.
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const t = await uploadPhoto(file);
|
||||||
|
setImages((list) => [...list, { key: t.key, url: t.publicUrl }]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(String((e as Error).message));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
const form = { name, category, position, imageKeys: images.map((i) => i.key) };
|
||||||
|
if (editing) await updateProduct(Number(id), form);
|
||||||
|
else await createProduct(form);
|
||||||
|
navigate('/admin');
|
||||||
|
} catch (err) {
|
||||||
|
setError(String((err as Error).message));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = (from: number, to: number) => {
|
||||||
|
if (to < 0 || to >= images.length) return;
|
||||||
|
setImages((list) => {
|
||||||
|
const next = [...list];
|
||||||
|
const [it] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, it);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-12 max-w-2xl">
|
||||||
|
<h1 className="font-adbhashitha text-4xl text-bakery-900 mb-8">
|
||||||
|
{editing ? 'Edit product' : 'Add a product'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{error && <p role="alert" className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-red-800">{error}</p>}
|
||||||
|
|
||||||
|
<form onSubmit={save} className="bg-white rounded-3xl p-8 shadow-xs">
|
||||||
|
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="name">Name</label>
|
||||||
|
<input
|
||||||
|
id="name" value={name} onChange={(e) => setName(e.target.value)} required
|
||||||
|
className="mb-5 w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="category">Category</label>
|
||||||
|
<input
|
||||||
|
id="category" value={category} onChange={(e) => setCategory(e.target.value)} required
|
||||||
|
placeholder="Cakes, Cookies, Rolls, Pie, Brownies, Pastries"
|
||||||
|
className="mb-1 w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500"
|
||||||
|
/>
|
||||||
|
<p className="mb-5 text-xs text-bakery-600">
|
||||||
|
A new category appears as its own filter button, after the ones the site already knows.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="photos">Photos</label>
|
||||||
|
<input
|
||||||
|
id="photos" type="file" accept="image/*" multiple disabled={busy}
|
||||||
|
onChange={(e) => onFiles(e.target.files)}
|
||||||
|
className="mb-4 block w-full text-sm text-bakery-700 file:mr-4 file:rounded-full file:border-0 file:bg-bakery-600 file:px-5 file:py-2 file:text-white"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="mb-6 grid grid-cols-3 gap-3">
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<div key={img.key} className="relative">
|
||||||
|
<img src={img.url} alt="" className="aspect-square w-full rounded-xl object-cover" />
|
||||||
|
{i === 0 && (
|
||||||
|
<span className="absolute top-1 left-1 rounded-full bg-bakery-900/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-white">
|
||||||
|
card
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex justify-between text-xs text-bakery-700">
|
||||||
|
<button type="button" onClick={() => move(i, i - 1)} aria-label="Move earlier">←</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setImages((l) => l.filter((_, j) => j !== i))}
|
||||||
|
className="text-red-700"
|
||||||
|
>
|
||||||
|
remove
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => move(i, i + 1)} aria-label="Move later">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || images.length === 0}
|
||||||
|
className="px-8 py-3 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? 'Working…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => navigate('/admin')} className="text-bakery-700 underline underline-offset-4">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{images.length === 0 && <span className="text-sm text-bakery-600">Add at least one photo.</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,8 +62,16 @@
|
|||||||
<groupId>net.thebennett.platform</groupId>
|
<groupId>net.thebennett.platform</groupId>
|
||||||
<artifactId>platform-starter-contact</artifactId>
|
<artifactId>platform-starter-contact</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- No starter-security: this is a public brochure site with nothing to sign in to, and no
|
<!-- Admin needs a login, and uploading a product photo needs the bucket. The public site
|
||||||
starter-storage: the photos are served straight from the public MinIO bucket. -->
|
still reads photos straight from the bucket's public URLs. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.thebennett.platform</groupId>
|
||||||
|
<artifactId>platform-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.thebennett.platform</groupId>
|
||||||
|
<artifactId>platform-starter-storage</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- test -->
|
<!-- test -->
|
||||||
<!-- Contract tests every app on the platform inherits. -->
|
<!-- Contract tests every app on the platform inherits. -->
|
||||||
@@ -77,6 +85,13 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- springSecurity() for MockMvc: without it the filter chain is absent and every protected
|
||||||
|
path answers 200, which would make a security test prove the opposite of what it says. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-testcontainers</artifactId>
|
<artifactId>spring-boot-testcontainers</artifactId>
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import com.itsthevine.web.domain.ContactEnquiry;
|
||||||
|
import com.itsthevine.web.domain.ContactEnquiryRepository;
|
||||||
|
import com.itsthevine.web.domain.Product;
|
||||||
|
import com.itsthevine.web.domain.ProductRepository;
|
||||||
|
|
||||||
|
import net.thebennett.platform.storage.StorageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything behind the login: the catalogue, and the enquiries people have sent.
|
||||||
|
*
|
||||||
|
* <p>The whole of {@code /api/admin/**} is gated by {@code platform.security.authenticated-paths}, so
|
||||||
|
* any signed-in Authentik user is an administrator here. That is deliberate for a two-person bakery —
|
||||||
|
* the alternative is a role model nobody would maintain.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin")
|
||||||
|
public class AdminController {
|
||||||
|
|
||||||
|
private final ProductRepository products;
|
||||||
|
private final ContactEnquiryRepository enquiries;
|
||||||
|
private final StorageService storage;
|
||||||
|
private final String bucket;
|
||||||
|
private final String publicBaseUrl;
|
||||||
|
|
||||||
|
public AdminController(ProductRepository products, ContactEnquiryRepository enquiries,
|
||||||
|
StorageService storage,
|
||||||
|
@Value("${vine.storage.bucket:itsthevine}") String bucket,
|
||||||
|
@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String publicBaseUrl) {
|
||||||
|
this.products = products;
|
||||||
|
this.enquiries = enquiries;
|
||||||
|
this.storage = storage;
|
||||||
|
this.bucket = bucket;
|
||||||
|
this.publicBaseUrl = publicBaseUrl.replaceAll("/+$", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- products ----
|
||||||
|
|
||||||
|
/** @param imageKeys bucket keys, in display order; the first is the one the card shows */
|
||||||
|
public record ProductForm(String name, String category, Integer position, List<String> imageKeys) {}
|
||||||
|
|
||||||
|
public record AdminProduct(Long id, String name, String category, int position,
|
||||||
|
List<String> imageKeys, List<String> imageUrls) {}
|
||||||
|
|
||||||
|
@GetMapping("/products")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<AdminProduct> list() {
|
||||||
|
return products.findAllByOrderByPositionAsc().stream().map(this::toAdmin).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/products")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@Transactional
|
||||||
|
public AdminProduct create(@RequestBody ProductForm form) {
|
||||||
|
validate(form);
|
||||||
|
// Default to the end of the list so a new product does not silently displace an existing one.
|
||||||
|
int position = form.position() != null ? form.position() : nextPosition();
|
||||||
|
return toAdmin(products.save(new Product(form.name().trim(), form.category().trim(),
|
||||||
|
position, cleanKeys(form.imageKeys()))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/products/{id}")
|
||||||
|
@Transactional
|
||||||
|
public AdminProduct update(@PathVariable Long id, @RequestBody ProductForm form) {
|
||||||
|
validate(form);
|
||||||
|
Product p = products.findById(id)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product"));
|
||||||
|
p.update(form.name().trim(), form.category().trim(),
|
||||||
|
form.position() != null ? form.position() : p.getPosition(),
|
||||||
|
cleanKeys(form.imageKeys()));
|
||||||
|
return toAdmin(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/products/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@Transactional
|
||||||
|
public void delete(@PathVariable Long id) {
|
||||||
|
if (!products.existsById(id)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product");
|
||||||
|
}
|
||||||
|
// The photos stay in the bucket: they are cheap, and an accidental delete is recoverable if
|
||||||
|
// the images survive it.
|
||||||
|
products.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- enquiries ----
|
||||||
|
|
||||||
|
/** @param delivered false means the relay refused it and nobody was notified */
|
||||||
|
public record AdminEnquiry(Long id, String name, String email, String message,
|
||||||
|
boolean delivered, Instant receivedAt) {}
|
||||||
|
|
||||||
|
@GetMapping("/enquiries")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<AdminEnquiry> enquiries() {
|
||||||
|
return enquiries.findAllByOrderByCreatedAtDesc().stream()
|
||||||
|
.map(e -> new AdminEnquiry(e.getId(), e.getName(), e.getEmail(), e.getMessage(),
|
||||||
|
e.isDelivered(), e.getCreatedAt()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- photo upload ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param key what to store on the product
|
||||||
|
* @param uploadUrl short-lived; the browser PUTs the file straight to the bucket so the photo
|
||||||
|
* never passes through this app
|
||||||
|
* @param publicUrl where it will be readable from afterwards
|
||||||
|
*/
|
||||||
|
public record UploadTarget(String key, String uploadUrl, String publicUrl) {}
|
||||||
|
|
||||||
|
@PostMapping("/images/presign-upload")
|
||||||
|
public UploadTarget presignUpload(@RequestParam String filename,
|
||||||
|
@RequestParam(defaultValue = "application/octet-stream") String contentType) {
|
||||||
|
// A UUID prefix rather than the bare filename: two people uploading "cake.jpg" must not
|
||||||
|
// overwrite each other, and the bucket is public so keys should not be guessable.
|
||||||
|
String safe = filename.toLowerCase().replaceAll("[^a-z0-9._-]", "-");
|
||||||
|
String key = "images/products/" + UUID.randomUUID() + "-" + safe;
|
||||||
|
return new UploadTarget(key.substring("images/".length()),
|
||||||
|
storage.presignPut(bucket, key, contentType).toString(),
|
||||||
|
publicBaseUrl + "/" + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private void validate(ProductForm form) {
|
||||||
|
if (form.name() == null || form.name().isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a name");
|
||||||
|
}
|
||||||
|
if (form.category() == null || form.category().isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a category");
|
||||||
|
}
|
||||||
|
if (form.imageKeys() == null || cleanKeys(form.imageKeys()).isEmpty()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs at least one photo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> cleanKeys(List<String> keys) {
|
||||||
|
return keys == null ? List.of()
|
||||||
|
: keys.stream().filter(k -> k != null && !k.isBlank()).map(String::trim).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int nextPosition() {
|
||||||
|
return products.findAllByOrderByPositionAsc().stream()
|
||||||
|
.mapToInt(Product::getPosition).max().orElse(0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminProduct toAdmin(Product p) {
|
||||||
|
return new AdminProduct(p.getId(), p.getName(), p.getCategory(), p.getPosition(),
|
||||||
|
p.getImageKeys(), p.getImageKeys().stream().map(this::publicUrl).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String publicUrl(String key) {
|
||||||
|
return publicBaseUrl + "/images/" + key.replaceAll("^/+", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who, if anyone, is signed in.
|
||||||
|
*
|
||||||
|
* <p>Deliberately PUBLIC: the SPA asks on every page load, and if this required a login the site would
|
||||||
|
* bounce anonymous visitors — every one of them — to Authentik just to render the front page.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class MeController {
|
||||||
|
|
||||||
|
/** @param admin true for any signed-in user; there is one level of access here */
|
||||||
|
public record Me(boolean authenticated, boolean admin, String name) {}
|
||||||
|
|
||||||
|
@GetMapping("/api/me")
|
||||||
|
public Me me() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
boolean signedIn = auth != null && auth.isAuthenticated()
|
||||||
|
&& !"anonymousUser".equals(auth.getPrincipal());
|
||||||
|
if (!signedIn) {
|
||||||
|
return new Me(false, false, null);
|
||||||
|
}
|
||||||
|
String name = auth.getName();
|
||||||
|
if (auth.getPrincipal() instanceof OidcUser user) {
|
||||||
|
name = user.getPreferredUsername() != null ? user.getPreferredUsername() : user.getSubject();
|
||||||
|
}
|
||||||
|
return new Me(true, true, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package com.itsthevine.web.domain;
|
package com.itsthevine.web.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface ContactEnquiryRepository extends JpaRepository<ContactEnquiry, Long> {
|
public interface ContactEnquiryRepository extends JpaRepository<ContactEnquiry, Long> {
|
||||||
|
|
||||||
|
/** Newest first — the admin screen reads like an inbox. */
|
||||||
|
List<ContactEnquiry> findAllByOrderByCreatedAtDesc();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import jakarta.persistence.JoinColumn;
|
|||||||
import jakarta.persistence.OrderColumn;
|
import jakarta.persistence.OrderColumn;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import org.hibernate.annotations.BatchSize;
|
||||||
|
|
||||||
import net.thebennett.platform.data.BaseEntity;
|
import net.thebennett.platform.data.BaseEntity;
|
||||||
|
|
||||||
/** Something the bakery makes, with the photos that show it off. */
|
/** Something the bakery makes, with the photos that show it off. */
|
||||||
@@ -32,17 +34,40 @@ public class Product extends BaseEntity {
|
|||||||
/**
|
/**
|
||||||
* Object keys, not URLs — where the bucket lives is deployment configuration, so the absolute
|
* Object keys, not URLs — where the bucket lives is deployment configuration, so the absolute
|
||||||
* URL is built at the edge of the app ({@code ProductCatalog}) rather than baked into the data.
|
* URL is built at the edge of the app ({@code ProductCatalog}) rather than baked into the data.
|
||||||
|
*
|
||||||
|
* <p>{@code @BatchSize} because the products page loads the whole catalogue at once: without it
|
||||||
|
* Hibernate issues a separate query per product for its photos — forty-odd round trips for a page
|
||||||
|
* that needs two.
|
||||||
*/
|
*/
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
@ElementCollection(fetch = FetchType.EAGER)
|
||||||
@CollectionTable(name = "product_image", joinColumns = @JoinColumn(name = "product_id"))
|
@CollectionTable(name = "product_image", joinColumns = @JoinColumn(name = "product_id"))
|
||||||
@OrderColumn(name = "position")
|
@OrderColumn(name = "position")
|
||||||
@Column(name = "image_key", nullable = false, length = 300)
|
@Column(name = "image_key", nullable = false, length = 300)
|
||||||
|
@BatchSize(size = 64)
|
||||||
private List<String> imageKeys = new ArrayList<>();
|
private List<String> imageKeys = new ArrayList<>();
|
||||||
|
|
||||||
protected Product() {
|
protected Product() {
|
||||||
// for JPA
|
// for JPA
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Product(String name, String category, int position, List<String> imageKeys) {
|
||||||
|
this.name = name;
|
||||||
|
this.category = category;
|
||||||
|
this.position = position;
|
||||||
|
this.imageKeys = new ArrayList<>(imageKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces every editable field — the admin form always submits the whole product. */
|
||||||
|
public void update(String name, String category, int position, List<String> imageKeys) {
|
||||||
|
this.name = name;
|
||||||
|
this.category = category;
|
||||||
|
this.position = position;
|
||||||
|
// Mutate in place rather than reassigning: Hibernate tracks THIS list instance, and handing it
|
||||||
|
// a different one makes it delete and re-insert every row.
|
||||||
|
this.imageKeys.clear();
|
||||||
|
this.imageKeys.addAll(imageKeys);
|
||||||
|
}
|
||||||
|
|
||||||
public String getName() { return name; }
|
public String getName() { return name; }
|
||||||
public String getCategory() { return category; }
|
public String getCategory() { return category; }
|
||||||
public int getPosition() { return position; }
|
public int getPosition() { return position; }
|
||||||
|
|||||||
@@ -36,11 +36,27 @@ platform:
|
|||||||
data:
|
data:
|
||||||
auditing:
|
auditing:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
security:
|
||||||
|
# Public site: only the admin API needs a login. An allowlist of public paths would mean
|
||||||
|
# enumerating every static directory, and anything missed 401s — which is exactly how the
|
||||||
|
# confessions site broke its own cover images. mode=OIDC comes from the deploy env so tests
|
||||||
|
# stay on NONE.
|
||||||
|
authenticated-paths:
|
||||||
|
- /api/admin/**
|
||||||
|
storage:
|
||||||
|
endpoint: ${S3_ENDPOINT:https://s3.thebennett.net}
|
||||||
|
access-key: ${S3_ACCESS_KEY:}
|
||||||
|
secret-key: ${S3_SECRET_KEY:}
|
||||||
|
path-style-access: true
|
||||||
contact:
|
contact:
|
||||||
to: ${CONTACT_TO:}
|
to: ${CONTACT_TO:}
|
||||||
from: ${CONTACT_FROM:}
|
from: ${CONTACT_FROM:}
|
||||||
hub-url: ${CONTACT_HUB_URL:}
|
hub-url: ${CONTACT_HUB_URL:}
|
||||||
|
|
||||||
|
vine:
|
||||||
|
storage:
|
||||||
|
bucket: ${VINE_BUCKET:itsthevine}
|
||||||
|
|
||||||
# Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL.
|
# Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL.
|
||||||
site:
|
site:
|
||||||
base-url: ${SITE_BASE_URL:https://itsthevine.com}
|
base-url: ${SITE_BASE_URL:https://itsthevine.com}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
|
import com.itsthevine.web.domain.ProductRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin catalogue operations. Whether they're reachable without a login is covered separately by
|
||||||
|
* {@link AdminSecurityTest} — this is about what they do once you're in.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
"[email protected]",
|
||||||
|
"[email protected]",
|
||||||
|
"platform.storage.access-key=test",
|
||||||
|
"platform.storage.secret-key=test",
|
||||||
|
"site.assets.base-url=https://s3.example.test/itsthevine"
|
||||||
|
})
|
||||||
|
@Testcontainers
|
||||||
|
class AdminControllerTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
@ServiceConnection
|
||||||
|
static PostgreSQLContainer<?> postgres =
|
||||||
|
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
AdminController admin;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
ProductCatalog catalog;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
ProductRepository products;
|
||||||
|
|
||||||
|
private static AdminController.ProductForm form(String name, String category, List<String> keys) {
|
||||||
|
return new AdminController.ProductForm(name, category, null, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsAProductAndItAppearsOnThePublicSite() {
|
||||||
|
int before = catalog.list(null).size();
|
||||||
|
|
||||||
|
var created = admin.create(form("Test Loaf", "Rolls", List.of("products/test-loaf.webp")));
|
||||||
|
|
||||||
|
assertThat(created.id()).isNotNull();
|
||||||
|
assertThat(catalog.list(null)).hasSize(before + 1);
|
||||||
|
assertThat(catalog.list("Rolls"))
|
||||||
|
.extracting(ProductCatalog.ProductView::name)
|
||||||
|
.contains("Test Loaf");
|
||||||
|
|
||||||
|
admin.delete(created.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aNewProductGoesToTheEndRatherThanDisplacingOne() {
|
||||||
|
// Position defaults matter: reusing an existing one would reorder the curated catalogue.
|
||||||
|
int maxBefore = admin.list().stream().mapToInt(AdminController.AdminProduct::position).max().orElse(0);
|
||||||
|
|
||||||
|
var created = admin.create(form("末 Loaf", "Rolls", List.of("products/x.webp")));
|
||||||
|
|
||||||
|
assertThat(created.position()).isGreaterThan(maxBefore);
|
||||||
|
admin.delete(created.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editingReplacesTheFieldsAndKeepsTheOrderOfPhotos() {
|
||||||
|
var created = admin.create(form("Before", "Cakes", List.of("products/a.webp", "products/b.webp")));
|
||||||
|
|
||||||
|
var updated = admin.update(created.id(),
|
||||||
|
new AdminController.ProductForm("After", "Pie", 3,
|
||||||
|
List.of("products/b.webp", "products/a.webp", "products/c.webp")));
|
||||||
|
|
||||||
|
assertThat(updated.name()).isEqualTo("After");
|
||||||
|
assertThat(updated.category()).isEqualTo("Pie");
|
||||||
|
assertThat(updated.position()).isEqualTo(3);
|
||||||
|
assertThat(updated.imageKeys())
|
||||||
|
.containsExactly("products/b.webp", "products/a.webp", "products/c.webp");
|
||||||
|
|
||||||
|
admin.delete(created.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aProductWithoutAPhotoIsRejected() {
|
||||||
|
// The card is a photo with a caption; without one it renders as an empty square.
|
||||||
|
assertThatThrownBy(() -> admin.create(form("No photo", "Cakes", List.of())))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.hasMessageContaining("at least one photo");
|
||||||
|
assertThatThrownBy(() -> admin.create(form("No photo", "Cakes", List.of(" "))))
|
||||||
|
.isInstanceOf(ResponseStatusException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aProductWithoutANameOrCategoryIsRejected() {
|
||||||
|
assertThatThrownBy(() -> admin.create(form(" ", "Cakes", List.of("products/a.webp"))))
|
||||||
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("name");
|
||||||
|
assertThatThrownBy(() -> admin.create(form("Thing", " ", List.of("products/a.webp"))))
|
||||||
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("category");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editingSomethingThatIsGoneIs404NotACrash() {
|
||||||
|
assertThatThrownBy(() -> admin.update(9_999_999L, form("x", "Cakes", List.of("products/a.webp"))))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.hasMessageContaining("404");
|
||||||
|
assertThatThrownBy(() -> admin.delete(9_999_999L))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.hasMessageContaining("404");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletingRemovesItFromThePublicCatalogue() {
|
||||||
|
var created = admin.create(form("Temporary", "Brownies", List.of("products/t.webp")));
|
||||||
|
assertThat(catalog.list("Brownies")).extracting(ProductCatalog.ProductView::name).contains("Temporary");
|
||||||
|
|
||||||
|
admin.delete(created.id());
|
||||||
|
|
||||||
|
assertThat(catalog.list("Brownies")).extracting(ProductCatalog.ProductView::name)
|
||||||
|
.doesNotContain("Temporary");
|
||||||
|
assertThat(products.findById(created.id())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminListsCarryBothKeysAndUrlsSoTheEditorCanShowThumbnails() {
|
||||||
|
var created = admin.create(form("Thumb", "Cookies", List.of("products/thumb.webp")));
|
||||||
|
|
||||||
|
var found = admin.list().stream().filter(p -> p.id().equals(created.id())).findFirst().orElseThrow();
|
||||||
|
assertThat(found.imageKeys()).containsExactly("products/thumb.webp");
|
||||||
|
assertThat(found.imageUrls())
|
||||||
|
.containsExactly("https://s3.example.test/itsthevine/images/products/thumb.webp");
|
||||||
|
|
||||||
|
admin.delete(created.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
import org.springframework.web.context.WebApplicationContext;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an anonymous visitor can and cannot reach.
|
||||||
|
*
|
||||||
|
* <p>This is the test that matters most on this branch: the admin API can create, edit and delete the
|
||||||
|
* menu, and the whole site is otherwise public. Running with {@code platform.security.mode=OIDC}, as
|
||||||
|
* production does — the default of NONE would leave everything open and prove nothing.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
"platform.security.mode=OIDC",
|
||||||
|
// Endpoints stated outright rather than an issuer-uri: an issuer-uri makes Spring fetch the
|
||||||
|
// discovery document at startup, which needs the network and a real identity provider.
|
||||||
|
"spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize",
|
||||||
|
"spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token",
|
||||||
|
"spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks",
|
||||||
|
"spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo",
|
||||||
|
"spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.scope=openid,profile,email",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.client-id=test",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.client-secret=test",
|
||||||
|
"[email protected]",
|
||||||
|
"[email protected]",
|
||||||
|
"platform.storage.access-key=test",
|
||||||
|
"platform.storage.secret-key=test"
|
||||||
|
})
|
||||||
|
@Testcontainers
|
||||||
|
class AdminSecurityTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
@ServiceConnection
|
||||||
|
static PostgreSQLContainer<?> postgres =
|
||||||
|
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
WebApplicationContext context;
|
||||||
|
|
||||||
|
MockMvc mvc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
// .apply(springSecurity()) is not optional here: webAppContextSetup alone leaves the security
|
||||||
|
// filter chain out, so every protected path returns 200 and the test proves nothing.
|
||||||
|
mvc = MockMvcBuilders.webAppContextSetup(context)
|
||||||
|
.apply(SecurityMockMvcConfigurers.springSecurity())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void everyAdminEndpointIsClosedToAnonymousVisitors() throws Exception {
|
||||||
|
// 401 rather than a redirect: the platform's security starter answers /api/** with a status so
|
||||||
|
// the SPA can handle it, instead of bouncing an XHR to the identity provider.
|
||||||
|
mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized());
|
||||||
|
mvc.perform(get("/api/admin/enquiries")).andExpect(status().isUnauthorized());
|
||||||
|
mvc.perform(post("/api/admin/products").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"name\":\"x\",\"category\":\"Cakes\",\"imageKeys\":[\"a\"]}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
mvc.perform(post("/api/admin/images/presign-upload?filename=x.jpg").with(csrf()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theShopStaysPublic() throws Exception {
|
||||||
|
// The whole point of authenticated-paths: locking the admin API must not lock the menu.
|
||||||
|
mvc.perform(get("/api/products")).andExpect(status().isOk());
|
||||||
|
mvc.perform(get("/api/categories")).andExpect(status().isOk());
|
||||||
|
mvc.perform(post("/api/contact").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"name\":\"Ada\",\"email\":\"nope\",\"message\":\"hi\"}"))
|
||||||
|
.andExpect(status().isBadRequest()); // reached the controller, rejected on content
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theContactFormNeedsItsCsrfToken() throws Exception {
|
||||||
|
// Turning on the security starter turns on CSRF, which applies to the PUBLIC contact form too.
|
||||||
|
// Without the token the form silently 403s — the SPA reads the XSRF-TOKEN cookie and sends
|
||||||
|
// X-XSRF-TOKEN for exactly this reason.
|
||||||
|
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"name\":\"Ada\",\"email\":\"[email protected]\",\"message\":\"hi\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void meIsPublicAndSaysNobodyIsSignedIn() throws Exception {
|
||||||
|
// If this required a login, every anonymous visitor would be bounced to Authentik on page load.
|
||||||
|
mvc.perform(get("/api/me"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.authenticated").value(false))
|
||||||
|
.andExpect(jsonPath("$.admin").value(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
import static org.mockito.Mockito.verifyNoInteractions;
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
@@ -15,8 +16,10 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.mail.MailSendException;
|
import org.springframework.mail.MailSendException;
|
||||||
import org.springframework.mail.SimpleMailMessage;
|
|
||||||
import org.springframework.mail.javamail.JavaMailSender;
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||||
|
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
import org.springframework.test.context.DynamicPropertySource;
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
@@ -47,6 +50,8 @@ class ContactControllerTest {
|
|||||||
// Activates the contact starter without pointing it at anything real.
|
// Activates the contact starter without pointing it at anything real.
|
||||||
registry.add("platform.contact.to", () -> "[email protected]");
|
registry.add("platform.contact.to", () -> "[email protected]");
|
||||||
registry.add("platform.contact.from", () -> "[email protected]");
|
registry.add("platform.contact.from", () -> "[email protected]");
|
||||||
|
registry.add("platform.storage.access-key", () -> "test");
|
||||||
|
registry.add("platform.storage.secret-key", () -> "test");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nothing in a test run may reach a real relay. */
|
/** Nothing in a test run may reach a real relay. */
|
||||||
@@ -65,6 +70,9 @@ class ContactControllerTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
|
// The contact starter builds a MimeMessage through the sender (platform 0.1.6, so the display
|
||||||
|
// name is quoted properly). A bare mock returns null for that, so give it a real one.
|
||||||
|
when(mailSender.createMimeMessage()).thenAnswer(i -> new JavaMailSenderImpl().createMimeMessage());
|
||||||
mvc = MockMvcBuilders.webAppContextSetup(context).build();
|
mvc = MockMvcBuilders.webAppContextSetup(context).build();
|
||||||
enquiries.deleteAll();
|
enquiries.deleteAll();
|
||||||
}
|
}
|
||||||
@@ -82,7 +90,7 @@ class ContactControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.ok").value(true));
|
.andExpect(jsonPath("$.ok").value(true));
|
||||||
|
|
||||||
verify(mailSender).send(any(SimpleMailMessage.class));
|
verify(mailSender).send(any(MimeMessage.class));
|
||||||
|
|
||||||
assertThat(enquiries.findAll()).singleElement().satisfies(e -> {
|
assertThat(enquiries.findAll()).singleElement().satisfies(e -> {
|
||||||
assertThat(e.getName()).isEqualTo("Ada");
|
assertThat(e.getName()).isEqualTo("Ada");
|
||||||
@@ -107,7 +115,7 @@ class ContactControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void keepsTheEnquiryWhenTheRelayIsDown() throws Exception {
|
void keepsTheEnquiryWhenTheRelayIsDown() throws Exception {
|
||||||
// The whole reason the row is written before delivery: a broken relay must not lose business.
|
// The whole reason the row is written before delivery: a broken relay must not lose business.
|
||||||
doThrow(new MailSendException("relay down")).when(mailSender).send(any(SimpleMailMessage.class));
|
doThrow(new MailSendException("relay down")).when(mailSender).send(any(MimeMessage.class));
|
||||||
|
|
||||||
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
|
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body("Ada", "[email protected]", "Cinnamon rolls for 30?")))
|
.content(body("Ada", "[email protected]", "Cinnamon rolls for 30?")))
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import net.thebennett.platform.test.PlatformWebContract;
|
|||||||
|
|
||||||
/** Everything in {@link PlatformWebContract} — what this app must do because it is on the platform. */
|
/** Everything in {@link PlatformWebContract} — what this app must do because it is on the platform. */
|
||||||
@SpringBootTest(properties = {
|
@SpringBootTest(properties = {
|
||||||
|
// The storage starter activates on its default endpoint, so an S3 client is built even in
|
||||||
|
// tests and fails on blank keys.
|
||||||
|
"platform.storage.access-key=test",
|
||||||
|
"platform.storage.secret-key=test",
|
||||||
"[email protected]",
|
"[email protected]",
|
||||||
"[email protected]"
|
"[email protected]"
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ class ProductCatalogTest {
|
|||||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||||
registry.add("site.assets.base-url", () -> "https://s3.example.test/itsthevine");
|
registry.add("site.assets.base-url", () -> "https://s3.example.test/itsthevine");
|
||||||
|
// The contact starter refuses to start on a blank recipient, and this app has a
|
||||||
|
// ContactController, so the context needs one even to test the catalogue.
|
||||||
|
registry.add("platform.contact.to", () -> "[email protected]");
|
||||||
|
registry.add("platform.contact.from", () -> "[email protected]");
|
||||||
|
registry.add("platform.storage.access-key", () -> "test");
|
||||||
|
registry.add("platform.storage.secret-key", () -> "test");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|||||||
Reference in New Issue
Block a user