import { metadata as homeMetadata } from '@/app/(frontend)/page'; import { metadata as rankingsMetadata } from '@/app/(frontend)/rankings/page'; import { metadata as admissionsMetadata } from '@/app/(frontend)/admissions/page'; import { generateMetadata as compareMetadata } from '@/app/(frontend)/compare/page'; import { metadata as rootMetadata } from '@/app/(frontend)/layout'; describe('canonical URLs', () => { it('the homepage canonicalises to the bare root', () => { // page.tsx reads eleven search params. Without this, every filter // combination is a crawlable near-duplicate of the one page we want to // rank for "compare schools". expect(homeMetadata.alternates?.canonical) .toBe('https://www.schoolcompare.co.uk/'); }); it('rankings canonicalises to the bare path', () => { expect(rankingsMetadata.alternates?.canonical) .toBe('https://www.schoolcompare.co.uk/rankings'); }); it('admissions canonicalises to the bare path', () => { expect(admissionsMetadata.alternates?.canonical) .toBe('https://www.schoolcompare.co.uk/admissions'); }); }); describe('/compare indexability', () => { it('the bare compare page is indexable and canonical to itself', async () => { // This is the landing page for the "compare schools" head term. const meta = await compareMetadata({ searchParams: Promise.resolve({}) }); expect(meta.alternates?.canonical) .toBe('https://www.schoolcompare.co.uk/compare'); expect(meta.robots).toBeUndefined(); }); it('a comparison of specific schools is noindex, follow', async () => { // ~317 million pairs before triples. Indexing the parameter space would // swamp everything else in the corpus. const meta = await compareMetadata({ searchParams: Promise.resolve({ urns: '100001,100002' }), }); expect(meta.robots).toEqual({ index: false, follow: true }); }); it('a parameterised comparison still canonicalises to the bare path', async () => { // follow:true plus a canonical means the outbound links to each school // page still pass value even though this URL is not indexed. const meta = await compareMetadata({ searchParams: Promise.resolve({ urns: '100001,100002' }), }); expect(meta.alternates?.canonical) .toBe('https://www.schoolcompare.co.uk/compare'); }); }); /* * W8 — snippet copy for the C1 cluster. * * The baseline (GSC, 16 months to 2026-08-20) showed these pages ranking on * page one and converting at a tenth of the normal rate: "compare school * performance" at position 6.1 with 0.43% CTR, against 9.16% for the brand * query from the same neighbourhood. The SERP is dominated by the DfE's own * "Compare school performance" service, so the job of this copy is to say * what that service does not offer, without losing intent match on the title. * * These tests guard the mechanics that make a snippet work — length, intent * keyword, differentiator, no brand-first — not the exact wording, which * should stay free to iterate. */ // Google truncates titles near 60 characters and descriptions near 155. const TITLE_MAX = 60; const DESC_MIN = 110; const DESC_MAX = 155; type Meta = { title?: unknown; description?: unknown }; const titleOf = (m: Meta): string => { const t = m.title as string | { absolute?: string } | undefined; return typeof t === 'string' ? t : (t?.absolute ?? ''); }; describe('C1 snippet copy', () => { const pages: Array<[string, Meta, RegExp]> = [ ['home', homeMetadata as Meta, /compare schools/i], ['rankings', rankingsMetadata as Meta, /league table/i], ['admissions', admissionsMetadata as Meta, /admission/i], ]; for (const [name, meta, intent] of pages) { it(`${name}: title carries the search intent and fits the SERP`, () => { const t = titleOf(meta); expect(t).toMatch(intent); expect(t.length).toBeLessThanOrEqual(TITLE_MAX); }); it(`${name}: title does not open with the brand`, () => { // The measured 0.43% CTR came from a brand-first title. The most // valuable pixels go to the thing the searcher typed. expect(titleOf(meta).toLowerCase().startsWith('schoolcompare')).toBe(false); }); it(`${name}: description is long enough to be worth reading, short enough to survive`, () => { const d = meta.description as string; expect(d.length).toBeGreaterThanOrEqual(DESC_MIN); expect(d.length).toBeLessThanOrEqual(DESC_MAX); }); } it('the homepage description names what gov.uk does not publish', () => { // Admissions distance is the one fact the DfE service has no equivalent // for. If it ever leaves this description, the snippet is competing with // gov.uk on gov.uk's own ground. expect(homeMetadata.description).toMatch(/close you had to live|distance/i); }); it('/compare targets the tool phrasing rather than repeating the homepage', () => { // Two pages chasing one phrase is how a site competes with itself. return compareMetadata({ searchParams: Promise.resolve({}) }).then((m) => { expect(m.title).toMatch(/comparison tool/i); expect(m.title).not.toBe(titleOf(homeMetadata as Meta)); }); }); it('no C1 page claims a school count that will drift', () => { // The corpus moves with every data refresh; this repo has already shipped // one copy bug of that kind ("three schools" against MAX_SCHOOLS = 5). for (const [, meta] of pages) { expect(meta.description as string).not.toMatch(/\b\d{2},\d{3}\b|\b\d{2},000\b/); } }); }); /** * The share card must be declared, not inherited. * * `app/opengraph-image.tsx` is a metadata file convention, and it does attach * to routes in the app root segment — `_not-found` gets an og:image from it. * It does NOT attach to the site's pages, which live in the `(frontend)` * route group whose own layout is a root layout. Staging served og:title, * og:description, og:url, og:site_name and og:type and no og:image at all, * so every link pasted into a chat rendered bare. * * The file stays at the app root, because /robots.txt and /icon.png depend on * it being there. The site's root layout points at the route it generates. */ describe('the share card', () => { it('declares an opengraph image on the site root layout', () => { // No og:image means every link pasted into a chat renders bare. const images = rootMetadata.openGraph?.images; expect(images).toBeTruthy(); expect(JSON.stringify(images)).toContain('/opengraph-image'); }); it('declares a twitter image too', () => { // twitter.card is summary_large_image. Claiming a large-image card and // supplying no image is worse than claiming a summary card. // Metadata['twitter'] is a union and `card` is not on every member, so // this reads the serialised shape rather than narrowing the type. const twitter = JSON.stringify(rootMetadata.twitter); expect(twitter).toContain('summary_large_image'); expect(twitter).toContain('/opengraph-image'); }); it('resolves the card to an absolute url via metadataBase', () => { // The e2e journey does `new URL(ogUrl)`, which throws on a relative path. expect(rootMetadata.metadataBase?.toString()) .toBe('https://www.schoolcompare.co.uk/'); }); });