Files
vue-seo-layer/tests/seo.test.ts

125 lines
6.1 KiB
TypeScript

import assert from 'node:assert/strict'
import test from 'node:test'
import {
SEO_STAGING_ROBOTS,
seoAbsolute,
seoAnswer,
seoHeadInput,
seoRedirectTarget,
seoResolvableUrl,
seoRobots,
seoVerificationName,
seoXRobotsTag,
} from '../app/utils/seo.ts'
import type { SeoResolveAnswer } from '../app/utils/types.ts'
const okAnswer = (over: Partial<SeoResolveAnswer['seo']> = {}, staging = false): SeoResolveAnswer => ({
action: 'ok',
status: 200,
url: 'https://acme.test/about',
locale: 'en',
staging,
seo: {
title: 'About | Acme',
description: 'Who we are',
canonical: 'https://acme.test/about',
robots: 'index, follow',
x_robots_tag: 'index, follow',
og: { title: 'About | Acme', description: 'Who we are', image: 'https://acme.test/a.png', url: 'https://acme.test/about' },
twitter: { card: 'summary_large_image', site: '@acme' },
verification: [],
...over,
},
})
test('resolvable URLs drop the hash and skip internal paths', () => {
assert.equal(seoResolvableUrl('/blog?page=2#top'), '/blog?page=2')
assert.equal(seoResolvableUrl('/'), '/')
for (const p of ['/_nuxt/a.js', '/__nuxt_error', '/_journal/api/v1/posts', '/api/x', '/api', '//evil.test/x', 'x']) {
assert.equal(seoResolvableUrl(p), null, p)
}
})
test('seoAnswer accepts the wire shape and rejects anything else', () => {
assert.equal(seoAnswer(null), null)
assert.equal(seoAnswer({ action: 'weird', status: 200 }), null)
assert.equal(seoAnswer({ action: 'ok' }), null)
assert.equal(seoAnswer({ action: 'ok', status: 200, seo: 'x' }), null)
const a = seoAnswer({ action: 'not_found', status: 404, url: '/x', staging: true })
assert.deepEqual(a && { action: a.action, staging: a.staging, seo: a.seo }, { action: 'not_found', staging: true, seo: null })
})
test('a redirect on the site origin becomes a path, another host stays external', () => {
const base = { action: 'redirect' as const, url: 'https://acme.test/old?x=1', locale: '', staging: false, seo: null }
assert.deepEqual(seoRedirectTarget({ ...base, status: 301, location: 'https://acme.test/new?x=1' }), { to: '/new?x=1', external: false, code: 301 })
assert.deepEqual(seoRedirectTarget({ ...base, status: 302, location: '/tmp' }), { to: '/tmp', external: false, code: 302 })
assert.deepEqual(seoRedirectTarget({ ...base, status: 301, location: 'https://other.test/p' }), { to: 'https://other.test/p', external: true, code: 301 })
assert.equal(seoRedirectTarget({ ...base, status: 301, location: 'javascript:alert(1)' }), null)
assert.equal(seoRedirectTarget({ ...base, status: 301 }), null)
assert.equal(seoRedirectTarget(okAnswer()), null)
})
test('robots: staging wins, ok answers carry their own, other answers none', () => {
assert.equal(seoRobots(okAnswer({}, true)), SEO_STAGING_ROBOTS)
assert.equal(seoRobots(okAnswer()), 'index, follow')
assert.equal(seoRobots({ action: 'not_found', status: 404, url: '', locale: '', staging: false, seo: null }), '')
assert.equal(seoRobots({ action: 'not_found', status: 404, url: '', locale: '', staging: true, seo: null }), SEO_STAGING_ROBOTS)
assert.equal(seoRobots(null), '')
assert.equal(seoXRobotsTag(okAnswer({}, true)), SEO_STAGING_ROBOTS)
assert.equal(seoXRobotsTag(okAnswer()), 'index, follow')
})
test('the head of an ok answer has every value and a final title', () => {
const head = seoHeadInput(okAnswer({ verification: [{ name: 'google-site-verification', content: 'abc' }] }))
assert.equal(head.title, 'About | Acme')
assert.equal(head.titleTemplate, '%s')
assert.deepEqual(head.link, [{ rel: 'canonical', href: 'https://acme.test/about' }])
const meta = Object.fromEntries(head.meta.map((m) => [m.name ?? m.property, m.content]))
assert.deepEqual(meta, {
robots: 'index, follow',
description: 'Who we are',
'og:title': 'About | Acme',
'og:description': 'Who we are',
'og:image': 'https://acme.test/a.png',
'og:url': 'https://acme.test/about',
'twitter:card': 'summary_large_image',
'twitter:site': '@acme',
'google-site-verification': 'abc',
})
})
test('empty values leave the page its own tags', () => {
const head = seoHeadInput(okAnswer({ title: '', description: '', canonical: '', og: { title: '', description: '', image: '', url: '' }, twitter: { card: '', site: '' } }))
assert.equal(head.title, undefined)
assert.equal(head.titleTemplate, undefined)
assert.deepEqual(head.link, [])
assert.deepEqual(head.meta, [{ name: 'robots', content: 'index, follow' }])
assert.deepEqual(seoHeadInput(null), { meta: [], link: [] })
assert.deepEqual(seoHeadInput({ action: 'not_found', status: 404, url: '', locale: '', staging: true, seo: null }), {
meta: [{ name: 'robots', content: SEO_STAGING_ROBOTS }],
link: [],
})
})
test('verification names: one segment, three extensions, never a reserved name', () => {
const reserved = ['robots.txt', 'sitemap.xml', 'rss.xml']
assert.equal(seoVerificationName('/google0123abc.html', reserved), 'google0123abc.html')
assert.equal(seoVerificationName('/BingSiteAuth.xml?x=1', reserved), 'BingSiteAuth.xml')
for (const p of ['/robots.txt', '/Sitemap.xml', '/rss.xml', '/a/b.html', '/x.php', '/.env.txt', '/', '/x..']) {
assert.equal(seoVerificationName(p, reserved), null, p)
}
})
test('paths in canonical and social URLs become absolute on the request origin', () => {
assert.equal(seoAbsolute('/a?b=1', 'https://acme.test'), 'https://acme.test/a?b=1')
assert.equal(seoAbsolute('/a', 'https://acme.test/'), 'https://acme.test/a')
assert.equal(seoAbsolute('https://x.test/a', 'https://acme.test'), 'https://x.test/a')
assert.equal(seoAbsolute('//x.test/a', 'https://acme.test'), '//x.test/a')
assert.equal(seoAbsolute('/a', undefined), '/a')
const head = seoHeadInput(okAnswer({ canonical: '/about', og: { title: '', description: '', image: '/img.png', url: '/about' } }), 'https://site.test')
assert.deepEqual(head.link, [{ rel: 'canonical', href: 'https://site.test/about' }])
const meta = Object.fromEntries(head.meta.map((m) => [m.name ?? m.property, m.content]))
assert.equal(meta['og:image'], 'https://site.test/img.png')
assert.equal(meta['og:url'], 'https://site.test/about')
})