diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 00000000..e6a0bb5d --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,17 @@ +// Required by Next.js App Router. Every app/ directory MUST have a root layout +// and it MUST include and — Next.js 14 will not inject them +// automatically, and the App Router runtime crashes on root path without them +// (causing a blank screen on localhost:3000/). +// +// Global CSS is imported here so App Router pages (e.g. not-found.tsx) get +// Tailwind and site styles. Pages Router routes continue to load CSS via +// pages/_app.tsx as before — importing the same file twice is fine (deduped). +import "../styles/index.css"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/not-found-client.tsx b/app/not-found-client.tsx new file mode 100644 index 00000000..5d09ee60 --- /dev/null +++ b/app/not-found-client.tsx @@ -0,0 +1,143 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import PostGrid from "../components/post-grid"; +import PostCard from "../components/post-card"; +import { Post } from "../types/post"; +import { getExcerpt } from "../utils/excerpt"; +import { FaSearch } from "react-icons/fa"; + +interface Props { + latestPosts?: { edges: Array<{ node: Post }> }; + communityPosts?: { edges: Array<{ node: Post }> }; + technologyPosts?: { edges: Array<{ node: Post }> }; +} + +export default function NotFoundClient({ latestPosts, communityPosts, technologyPosts }: Props) { + const [countdown, setCountdown] = useState(12); + const [searchTerm, setSearchTerm] = useState(""); + + useEffect(() => { + const interval = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { clearInterval(interval); window.location.href = "/blog"; return 0; } + return prev - 1; + }); + }, 1000); + return () => clearInterval(interval); + }, []); + + const formatTime = (s: number) => s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`; + + const allPosts = useMemo(() => + [...(latestPosts?.edges || []), ...(communityPosts?.edges || []), ...(technologyPosts?.edges || [])] + .filter((p, i, self) => i === self.findIndex((x) => x.node.slug === p.node.slug)), + [latestPosts, communityPosts, technologyPosts] + ); + + const filteredPosts = useMemo(() => { + const t = searchTerm.toLowerCase(); + return allPosts.filter(({ node }) => + (node.title || "").toLowerCase().includes(t) || (node.excerpt || "").toLowerCase().includes(t) + ); + }, [allPosts, searchTerm]); + + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

+ + Oops! 404 Not Found... + +

+

+ Looks like you have wandered off the beaten path. Our team is working to get you back on track and find what you are looking for. +

+
+
+ + Back To Home + + +
+

+ Wait for {formatTime(countdown)} for automatic redirect or click the buttons above or explore our latest blog posts below. +

+
+
+
+
+ 404 Error Illustration +
+
+
+ +
+
+ setSearchTerm(e.target.value)} + className="w-full p-4 pl-10 rounded-full border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> + +
+
+ + {searchTerm ? ( +
+

Search Results

+ {filteredPosts.length === 0 + ?

No posts found matching that search

+ : {filteredPosts.map(({ node: post }) => e.node.name === "community")} />)} + } +
+ ) : ( + <> + {(latestPosts?.edges?.length ?? 0) > 0 && ( +
+

Latest from Our Blog

+ {(latestPosts?.edges ?? []).slice(0, 6).map(({ node: post }) => e.node.name === "community")} />)} +
+ )} + {(communityPosts?.edges?.length ?? 0) > 0 && ( +
+

Latest Community Blogs

+ {(communityPosts?.edges ?? []).slice(0, 6).map(({ node: post }) => )} +
+ )} + {(technologyPosts?.edges?.length ?? 0) > 0 && ( +
+

Latest Technology Blogs

+ {(technologyPosts?.edges ?? []).slice(0, 6).map(({ node: post }) => )} +
+ )} + + )} +
+
+ ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 00000000..a9e5dffb --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,58 @@ +// Required when app/ directory is present alongside pages/. +// Without this file, any unmatched App Router path shows a blank +// "__next_error__" shell instead of a usable page. +// +// This file renders a standalone 404 UI (NotFoundClient) — it does NOT redirect. +// redirect() cannot be used here: it is an RSC soft-navigation, not a hard HTTP +// redirect. Since /404 has no App Router page, calling redirect('/404') causes +// the router to invoke this boundary again → infinite loop. +// +// Header/FloatingNavbar/NotFoundPage are intentionally not imported here because +// they use useRouter() from next/router, which throws "NextRouter was not mounted" +// in App Router context. Pages Router components are left completely unchanged. +import { getAllPostsForTechnology, getAllPostsForCommunity } from "../lib/api"; +import NotFoundClient from "./not-found-client"; + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timeoutId: ReturnType | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`NotFound posts fetch timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + }); +} + +export default async function NotFound() { + let latestPosts = { edges: [] as any[] }; + let communityPosts = { edges: [] as any[] }; + let technologyPosts = { edges: [] as any[] }; + + try { + // Posts are decorative; keep the 404 UI fast even if WordPress is slow/hung. + const [techPosts, commPosts] = await withTimeout(Promise.all([ + getAllPostsForTechnology(false, null), + getAllPostsForCommunity(false, null), + ]), 3000); + const allEdges = [...techPosts.edges, ...commPosts.edges].sort( + (a, b) => new Date(b.node.date).getTime() - new Date(a.node.date).getTime() + ); + latestPosts = { edges: allEdges.slice(0, 6) }; + technologyPosts = { edges: techPosts.edges.slice(0, 6) }; + communityPosts = { edges: commPosts.edges.slice(0, 6) }; + } catch { + // Posts are decorative — render without them rather than blank screen. + } + + return ( + + ); +} diff --git a/app/sitemap.xml/route.ts b/app/sitemap.xml/route.ts new file mode 100644 index 00000000..ee17f20a --- /dev/null +++ b/app/sitemap.xml/route.ts @@ -0,0 +1,78 @@ +import { getAllPostsForSitemap } from "../../lib/api-server"; +import { + adaptPostsForSitemap, + assertFullSitemap, + buildAuthorEntries, + buildPostEntries, + buildTagEntries, + dedupeEntries, + getLatestModified, + getStaticFallbackXml, + serializeSitemap, + STATIC_ROUTES, +} from "../../lib/sitemap"; + +// ISR: Vercel caches this response in its CDN edge cache for 1 hour. +// +// After first generation: every request is served from CDN (<10ms, no Lambda invoked). +// After TTL expires: stale version served immediately, regeneration happens in background. +// If WordPress is down during regen: Vercel keeps serving the previous good version automatically. +export const revalidate = 3600; + +export async function GET(): Promise { + try { + // getAllPostsForSitemap() uses node:https directly (see lib/api-server.ts) to + // bypass Next.js App Router's RSC fetch instrumentation, which causes Cloudflare + // to return 502 HTML when global fetch() is used in this context. + const allPostsResult = await getAllPostsForSitemap(); + const posts = adaptPostsForSitemap(allPostsResult); + + // Reject partial WordPress responses — ISR will not cache a thrown error, + // so Vercel keeps serving the previous good cached version automatically. + assertFullSitemap(posts); + + // Static routes get lastmod = newest post modification time, + // so listing pages reflect when the freshest underlying content changed. + const latestModified = getLatestModified(posts) ?? new Date().toISOString(); + const staticEntries = STATIC_ROUTES.map((r) => ({ + ...r, + lastModified: latestModified, + })); + + const entries = dedupeEntries([ + ...staticEntries, + ...buildPostEntries(posts), + ...buildAuthorEntries(posts), + ...buildTagEntries(posts), + ]); + + const xml = serializeSitemap(entries); + + return new Response(xml, { + status: 200, + headers: { + "Content-Type": "application/xml", + // s-maxage: CDN caches for 1h. stale-while-revalidate: serve stale while + // regenerating in background. max-age=0: browsers always revalidate with CDN. + "Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=3600", + }, + }); + } catch (error) { + console.error( + "Sitemap generation failed — serving static-routes-only fallback. " + + "Verify WORDPRESS_API_URL is reachable and WPGraphQL is responding.", + error + ); + + // ISR does NOT cache non-2xx responses. + // no-store prevents any downstream proxy from caching this degraded response, + // so crawlers will retry on the next request once WordPress is back. + return new Response(getStaticFallbackXml(), { + status: 503, + headers: { + "Content-Type": "application/xml", + "Cache-Control": "no-store", + }, + }); + } +} diff --git a/components/AuthorMapping.tsx b/components/AuthorMapping.tsx index 352f9b58..280d82fa 100644 --- a/components/AuthorMapping.tsx +++ b/components/AuthorMapping.tsx @@ -13,8 +13,8 @@ export default function AuthorMapping({ }) { const [currentPage, setCurrentPage] = useState(1); - const authorData = []; - const ppmaAuthorNameArray = []; + const authorData: Array<{ publishingAuthor: string; ppmaAuthorName: string; avatarUrl: string; slug: string }> = []; + const ppmaAuthorNameArray: string[] = []; AuthorArray.forEach((item) => { const ppmaAuthorName = formatAuthorName(item.ppmaAuthorName); diff --git a/components/NotFoundPage.tsx b/components/NotFoundPage.tsx index 2456c468..2e53ec0b 100644 --- a/components/NotFoundPage.tsx +++ b/components/NotFoundPage.tsx @@ -219,13 +219,13 @@ const NotFoundPage = ({ latestPosts, communityPosts, technologyPosts }: NotFound ) : ( <> - {latestPosts?.edges?.length > 0 && ( + {(latestPosts?.edges?.length ?? 0) > 0 && (

Latest from Our Blog

- {latestPosts.edges.slice(0, 6).map(({ node: post }) => ( + {(latestPosts?.edges ?? []).slice(0, 6).map(({ node: post }) => ( )} - {communityPosts?.edges?.length > 0 && ( + {(communityPosts?.edges?.length ?? 0) > 0 && (

Latest Community Blogs

- {communityPosts.edges.slice(0, 6).map(({ node: post }) => ( + {(communityPosts?.edges ?? []).slice(0, 6).map(({ node: post }) => ( )} - {technologyPosts?.edges?.length > 0 && ( + {(technologyPosts?.edges?.length ?? 0) > 0 && (

Latest Technology Blogs

- {technologyPosts.edges.slice(0, 6).map(({ node: post }) => ( + {(technologyPosts?.edges ?? []).slice(0, 6).map(({ node: post }) => ( >(null); + const timeout = useRef | null>(null); const wrapperRef = useRef(null); const handleEnter = () => { @@ -106,7 +106,7 @@ export default function TOC({ headings, isList, setIsList }) { const element = document.getElementById(sanitizedId); if (element) { window.scrollTo({ top: element.offsetTop - 80, behavior: "smooth" }); - window.history.replaceState(null, null, `#${sanitizedId}`); + window.history.replaceState(null, "", `#${sanitizedId}`); } }; diff --git a/components/more-stories.tsx b/components/more-stories.tsx index 93671c73..b1e3a9c6 100644 --- a/components/more-stories.tsx +++ b/components/more-stories.tsx @@ -40,7 +40,7 @@ export default function MoreStories({ const [visibleCount, setVisibleCount] = useState(12); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(initialPageInfo?.hasNextPage ?? true); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [endCursor, setEndCursor] = useState(initialPageInfo?.endCursor ?? null); const [buffer, setBuffer] = useState<{ node: Post }[]>([]); const [searchLoading, setSearchLoading] = useState(false); diff --git a/components/post-body.tsx b/components/post-body.tsx index 60890268..fda288ea 100644 --- a/components/post-body.tsx +++ b/components/post-body.tsx @@ -47,9 +47,9 @@ export default function PostBody({ slug: string | string[] | undefined; categories?: Post["categories"]; }) { - const [tocItems, setTocItems] = useState([]); - const [copySuccessList, setCopySuccessList] = useState([]); - const [headingCopySuccessList, setHeadingCopySuccessList] = useState([]); + const [tocItems, setTocItems] = useState<{ id: string; title: string | null; type: string }[]>([]); + const [copySuccessList, setCopySuccessList] = useState([]); + const [headingCopySuccessList, setHeadingCopySuccessList] = useState([]); const [isSmallScreen, setIsSmallScreen] = useState(false); const [replacedContent, setReplacedContent] = useState(content || ""); const [isList, setIsList] = useState(false); diff --git a/lib/api-server.ts b/lib/api-server.ts new file mode 100644 index 00000000..88e91e65 --- /dev/null +++ b/lib/api-server.ts @@ -0,0 +1,160 @@ +/** + * Server-only API helpers. + * + * This module uses node:https directly instead of the global fetch() because + * Next.js App Router's RSC context wraps fetch() with its own instrumentation + * layer. That instrumented fetch sends headers that Cloudflare (in front of + * wp.keploy.io) rejects with 502 HTML instead of JSON. + * + * node:https is a raw TCP connection — no Next.js middleware in the path — + * identical to what curl or plain `node -e "fetch()"` sends. + * + * DO NOT import this file from client components. It uses node:https which + * does not exist in the browser. All client-side API calls go through + * lib/api.ts which uses the standard fetch() API. + */ +import "server-only"; +import http from "node:http"; +import https from "node:https"; + +function getApiUrl(): string { + const url = process.env.WORDPRESS_API_URL || process.env.NEXT_PUBLIC_WORDPRESS_API_URL; + if (!url) { + throw new Error( + "WordPress API URL is not configured. Set WORDPRESS_API_URL in your environment. " + + "NEXT_PUBLIC_WORDPRESS_API_URL is derived from it in next.config.js (NEXT_PUBLIC is only a local/test fallback here)." + ); + } + return url; +} + +function fetchGraphQL(query: string, variables: Record = {}): Promise { + const apiUrl = getApiUrl(); + const url = new URL(apiUrl); + const body = JSON.stringify({ query, variables }); + + // Use http for local/test endpoints (e.g. http://localhost:4000/graphql), + // https for production. Determined by the protocol in the configured URL. + const transport = url.protocol === "https:" ? https : http; + const defaultPort = url.protocol === "https:" ? 443 : 80; + + // 25s per page request — well under Vercel's 30s ISR timeout and leaves + // headroom for the pagination loop to complete across ~10 pages. + const TIMEOUT_MS = 25_000; + + return new Promise((resolve, reject) => { + const req = transport.request( + { + hostname: url.hostname, + port: url.port || defaultPort, + path: url.pathname + url.search, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + "User-Agent": "keploy-blog-sitemap/1.0", + }, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + const status = res.statusCode ?? 0; + if (status < 200 || status >= 300) { + reject( + new Error( + `WordPress returned HTTP ${status}: ${data.slice(0, 120)}` + ) + ); + return; + } + try { + const json = JSON.parse(data); + if (json.errors) reject(new Error(JSON.stringify(json.errors))); + else resolve(json.data); + } catch { + reject( + new Error( + `WordPress returned non-JSON (HTTP ${status}): ${data.slice(0, 120)}` + ) + ); + } + }); + } + ); + req.setTimeout(TIMEOUT_MS, () => { + req.destroy( + new Error(`GraphQL request timed out after ${TIMEOUT_MS}ms — WordPress may be slow or unreachable`) + ); + }); + req.on("error", reject); + req.write(body); + req.end(); + }); +} + +/** + * Fetches all posts with only the fields needed for sitemap generation. + * Uses the same cursor-based pagination as getAllPosts() in lib/api.ts but + * requests a smaller field set (no title/excerpt/featuredImage) to reduce + * payload size across the ~10 pagination pages. + */ +export async function getAllPostsForSitemap(): Promise<{ edges: any[] }> { + let allEdges: any[] = []; + let hasNextPage = true; + let endCursor: string | null = null; + + while (hasNextPage) { + const data = await fetchGraphQL( + ` + query AllPostsForSitemap($after: String) { + posts(first: 50, after: $after, where: { orderby: { field: DATE, order: DESC } }) { + edges { + node { + slug + modified + ppmaAuthorName + categories { + edges { + node { + name + slug + } + } + } + tags { + edges { + node { + name + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + `, + { after: endCursor } + ); + + const edges = data?.posts?.edges ?? []; + allEdges.push(...edges); + const nextCursor = data?.posts?.pageInfo?.endCursor ?? null; + hasNextPage = data?.posts?.pageInfo?.hasNextPage ?? false; + // Guard: if WordPress claims there is a next page but returns no advancing cursor, + // the loop would re-fetch the same page forever and hang ISR regeneration. + if (hasNextPage && !nextCursor) { + throw new Error("WordPress pagination error: hasNextPage is true but endCursor is missing"); + } + if (hasNextPage && nextCursor === endCursor) { + throw new Error("WordPress pagination error: endCursor did not advance between pages"); + } + endCursor = nextCursor; + } + + return { edges: allEdges }; +} diff --git a/lib/api.ts b/lib/api.ts index fd596f50..5f53ce30 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,7 +1,15 @@ export const maxDuration = 300; // This can run Vercel Functions for a maximum of 300 seconds export const dynamic = 'force-dynamic'; -const API_URL = process.env.WORDPRESS_API_URL || process.env.NEXT_PUBLIC_WORDPRESS_API_URL +const API_URL: string = (() => { + const url = process.env.WORDPRESS_API_URL || process.env.NEXT_PUBLIC_WORDPRESS_API_URL; + if (!url) { + throw new Error( + "WordPress API URL is not configured. Set WORDPRESS_API_URL in your environment variables (next.config.js requires it at startup and derives NEXT_PUBLIC_WORDPRESS_API_URL from it)." + ); + } + return url; +})(); /** * Normalize a post node from WordPress — default null title/excerpt to empty @@ -75,8 +83,8 @@ export async function getPreviewPost(id, idType = "DATABASE_ID") { export async function getAllTags() { let hasNextPage = true; - let endCursor = null; - let allTags = []; + let endCursor: string | null = null; + let allTags: any[] = []; while (hasNextPage) { const data = await fetchAPI( @@ -103,11 +111,35 @@ export async function getAllTags() { } ); - const tags = data?.tags?.edges.map((edge) => edge.node); + const edges = data?.tags?.edges; + if (!Array.isArray(edges)) { + throw new Error( + "WordPress GraphQL response missing tags.edges for AllTags query. " + + "Verify WORDPRESS_API_URL is reachable and WPGraphQL is returning the expected schema." + ); + } + const tags = edges.map((edge) => edge.node); allTags = allTags.concat(tags); - hasNextPage = data?.tags?.pageInfo?.hasNextPage; - endCursor = data?.tags?.pageInfo?.endCursor; + const pageInfo = data?.tags?.pageInfo; + if (!pageInfo) { + throw new Error( + "WordPress GraphQL response missing tags.pageInfo for AllTags query. " + + "Verify WORDPRESS_API_URL is reachable and WPGraphQL is returning the expected schema." + ); + } + + const nextCursor = pageInfo.endCursor ?? null; + hasNextPage = pageInfo.hasNextPage ?? false; + // Guard: if WordPress claims there is a next page but returns no advancing cursor, + // the loop would re-fetch the same page forever. + if (hasNextPage && !nextCursor) { + throw new Error("WordPress pagination error: hasNextPage is true but endCursor is missing (AllTags)"); + } + if (hasNextPage && nextCursor === endCursor) { + throw new Error("WordPress pagination error: endCursor did not advance between pages (AllTags)"); + } + endCursor = nextCursor; } return allTags; } @@ -159,9 +191,9 @@ export async function getAllPostsFromTags(tagName: String, preview) { } export async function getAllPosts() { - let allEdges = []; + let allEdges: any[] = []; let hasNextPage = true; - let endCursor = null; + let endCursor: string | null = null; while (hasNextPage) { const data = await fetchAPI( @@ -174,6 +206,7 @@ export async function getAllPosts() { excerpt slug date + modified postId featuredImage { node { @@ -187,6 +220,14 @@ export async function getAllPosts() { } ppmaAuthorName categories { + edges { + node { + name + slug + } + } + } + tags { edges { node { name @@ -195,6 +236,10 @@ export async function getAllPosts() { } } } + pageInfo { + hasNextPage + endCursor + } } } `, @@ -204,9 +249,33 @@ export async function getAllPosts() { ); const edges = data?.posts?.edges; + if (!Array.isArray(edges)) { + throw new Error( + "WordPress GraphQL response missing posts.edges for AllPosts query. " + + "Verify WORDPRESS_API_URL is reachable and WPGraphQL is returning the expected schema." + ); + } allEdges = [...allEdges, ...edges]; - hasNextPage = data?.posts?.pageInfo?.hasNextPage; - endCursor = data?.posts?.pageInfo?.endCursor; + + const pageInfo = data?.posts?.pageInfo; + if (!pageInfo) { + throw new Error( + "WordPress GraphQL response missing posts.pageInfo for AllPosts query. " + + "Verify WORDPRESS_API_URL is reachable and WPGraphQL is returning the expected schema." + ); + } + + const nextCursor = pageInfo.endCursor ?? null; + hasNextPage = pageInfo.hasNextPage ?? false; + // Guard: if WordPress claims there is a next page but returns no advancing cursor, + // the loop would re-fetch the same page forever. + if (hasNextPage && !nextCursor) { + throw new Error("WordPress pagination error: hasNextPage is true but endCursor is missing"); + } + if (hasNextPage && nextCursor === endCursor) { + throw new Error("WordPress pagination error: endCursor did not advance between pages"); + } + endCursor = nextCursor; } return { edges: allEdges }; @@ -406,9 +475,9 @@ export async function getAllPostsForCommunity(preview = false, after = null) { } export async function getAllAuthors() { - let allAuthors = []; + let allAuthors: any[] = []; let hasNextPage = true; - let endCursor = null; + let endCursor: string | null = null; while (hasNextPage) { const data = await fetchAPI( @@ -444,17 +513,41 @@ export async function getAllAuthors() { ); const edges = data?.posts?.edges; + if (!Array.isArray(edges)) { + throw new Error( + "WordPress GraphQL response missing posts.edges for getAllAuthors query. " + + "Verify WORDPRESS_API_URL is reachable and WPGraphQL is returning the expected schema." + ); + } allAuthors = [...allAuthors, ...edges]; - hasNextPage = data?.posts?.pageInfo?.hasNextPage; - endCursor = data?.posts?.pageInfo?.endCursor; + + const pageInfo = data?.posts?.pageInfo; + if (!pageInfo) { + throw new Error( + "WordPress GraphQL response missing posts.pageInfo for getAllAuthors query. " + + "Verify WORDPRESS_API_URL is reachable and WPGraphQL is returning the expected schema." + ); + } + + const nextCursor = pageInfo.endCursor ?? null; + hasNextPage = pageInfo.hasNextPage ?? false; + // Guard: if WordPress claims there is a next page but returns no advancing cursor, + // the loop would re-fetch the same page forever. + if (hasNextPage && !nextCursor) { + throw new Error("WordPress pagination error: hasNextPage is true but endCursor is missing (getAllAuthors)"); + } + if (hasNextPage && nextCursor === endCursor) { + throw new Error("WordPress pagination error: endCursor did not advance between pages (getAllAuthors)"); + } + endCursor = nextCursor; } return { edges: allAuthors }; } export async function getPostsByAuthor() { - let allPosts = []; + let allPosts: any[] = []; let hasNextPage = true; - let endCursor = null; + let endCursor: string | null = null; while (hasNextPage) { const data = await fetchAPI( @@ -494,9 +587,33 @@ export async function getPostsByAuthor() { ); const edges = data?.posts?.edges; + if (!Array.isArray(edges)) { + throw new Error( + "WordPress GraphQL response missing posts.edges for getPostsByAuthor query. " + + "Verify WORDPRESS_API_URL is reachable and WPGraphQL is returning the expected schema." + ); + } allPosts = [...allPosts, ...edges]; - hasNextPage = data?.posts?.pageInfo?.hasNextPage; - endCursor = data?.posts?.pageInfo?.endCursor; + + const pageInfo = data?.posts?.pageInfo; + if (!pageInfo) { + throw new Error( + "WordPress GraphQL response missing posts.pageInfo for getPostsByAuthor query. " + + "Verify WORDPRESS_API_URL is reachable and WPGraphQL is returning the expected schema." + ); + } + + const nextCursor = pageInfo.endCursor ?? null; + hasNextPage = pageInfo.hasNextPage ?? false; + // Guard: if WordPress claims there is a next page but returns no advancing cursor, + // the loop would re-fetch the same page forever. + if (hasNextPage && !nextCursor) { + throw new Error("WordPress pagination error: hasNextPage is true but endCursor is missing (getPostsByAuthor)"); + } + if (hasNextPage && nextCursor === endCursor) { + throw new Error("WordPress pagination error: endCursor did not advance between pages (getPostsByAuthor)"); + } + endCursor = nextCursor; } return { edges: allPosts }; } @@ -504,7 +621,7 @@ export async function getPostsByAuthor() { export async function getMoreStoriesForSlugs(tags, slug) { const tagFilter = tags?.edges?.length > 0; const variables = tagFilter ? { tags: tags.edges.map((edge) => edge.node.name) } : undefined; - let stories = []; + let stories: any[] = []; let data; const queryWithTags = ` @@ -873,4 +990,4 @@ export async function getAllPostsForSearch(preview = false) { ); return data?.posts?.edges || []; -} \ No newline at end of file +} diff --git a/lib/google-search-console.ts b/lib/google-search-console.ts new file mode 100644 index 00000000..315555ac --- /dev/null +++ b/lib/google-search-console.ts @@ -0,0 +1,144 @@ +import crypto from "crypto"; +import { SITE_URL } from "./structured-data"; + +const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_WEBMASTERS_SCOPE = "https://www.googleapis.com/auth/webmasters"; +const GOOGLE_SITEMAPS_SUBMIT_BASE_URL = "https://www.googleapis.com/webmasters/v3/sites"; +const GOOGLE_TOKEN_LIFETIME_SECONDS = 3600; +const GOOGLE_FETCH_TIMEOUT_MS = 25000; + +function getRequiredEnv(name: string) { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +function base64UrlEncode(value: Buffer | string) { + return Buffer.from(value) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function getGooglePrivateKey() { + return getRequiredEnv("GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY").replace(/\\n/g, "\n"); +} + +function createServiceAccountJwt() { + const clientEmail = getRequiredEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"); + const now = Math.floor(Date.now() / 1000); + + const header = { + alg: "RS256", + typ: "JWT", + }; + + const payload = { + iss: clientEmail, + scope: GOOGLE_WEBMASTERS_SCOPE, + aud: GOOGLE_OAUTH_TOKEN_URL, + exp: now + GOOGLE_TOKEN_LIFETIME_SECONDS, + iat: now, + }; + + const encodedHeader = base64UrlEncode(JSON.stringify(header)); + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + const unsignedToken = `${encodedHeader}.${encodedPayload}`; + + const signer = crypto.createSign("RSA-SHA256"); + signer.update(unsignedToken); + signer.end(); + + const signature = signer.sign(getGooglePrivateKey()); + + return `${unsignedToken}.${base64UrlEncode(signature)}`; +} + +async function fetchGoogleAccessToken() { + const assertion = createServiceAccountJwt(); + + const response = await fetch(GOOGLE_OAUTH_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion, + }), + signal: AbortSignal.timeout(GOOGLE_FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + throw new Error( + `Google OAuth token request failed: ${response.status} ${response.statusText}${ + errorBody ? ` - ${errorBody}` : "" + }` + ); + } + + const json = (await response.json()) as { + access_token?: string; + }; + + if (!json.access_token) { + throw new Error("Google OAuth token response did not include an access token"); + } + + return json.access_token; +} + +function getSearchConsoleSiteUrl() { + return getRequiredEnv("GOOGLE_SEARCH_CONSOLE_SITE_URL"); +} + +function getSitemapUrl() { + return process.env.SITEMAP_PUBLIC_URL?.trim() || `${SITE_URL}/sitemap.xml`; +} + +export function isSearchConsoleSubmissionConfigured() { + return Boolean( + process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL?.trim() && + process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY?.trim() && + process.env.GOOGLE_SEARCH_CONSOLE_SITE_URL?.trim() + ); +} + +export async function submitSitemapToSearchConsole() { + const accessToken = await fetchGoogleAccessToken(); + const siteUrl = getSearchConsoleSiteUrl(); + const sitemapUrl = getSitemapUrl(); + + const response = await fetch( + `${GOOGLE_SITEMAPS_SUBMIT_BASE_URL}/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent( + sitemapUrl + )}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + signal: AbortSignal.timeout(GOOGLE_FETCH_TIMEOUT_MS), + } + ); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + throw new Error( + `Google Search Console sitemap submission failed: ${response.status} ${response.statusText}${ + errorBody ? ` - ${errorBody}` : "" + }` + ); + } + + return { + siteUrl, + sitemapUrl, + submittedAt: new Date().toISOString(), + }; +} diff --git a/lib/sitemap.ts b/lib/sitemap.ts new file mode 100644 index 00000000..e76ecb37 --- /dev/null +++ b/lib/sitemap.ts @@ -0,0 +1,261 @@ +import { SITE_URL } from "./structured-data"; +import { sanitizeAuthorSlug } from "../utils/sanitizeAuthorSlug"; +import { sanitizeStringForURL } from "../utils/sanitizeStringForUrl"; + +type SitemapChangeFrequency = "daily" | "weekly" | "monthly"; +type CategoryRoute = "technology" | "community"; + +export type SitemapEntry = { + // final absolute url that will appear inside . + url: string; + + // final normalized timestamp that will appear inside when available. + lastModified?: string; + + // optional sitemap hint for crawlers. + changeFrequency?: SitemapChangeFrequency; + + // optional sitemap hint for relative importance. + priority?: number; +}; + +// internal shape used by all entry builders. +// populated via adaptPostsForSitemap() from the getAllPosts() return value. +type SitemapPost = { + slug: string; + modified?: string; + authorName?: string; + tags: string[]; + routes: CategoryRoute[]; +}; + +export const STATIC_ROUTES: Array> = [ + // top-level listing and navigation pages that should always be in the sitemap. + { url: SITE_URL, changeFrequency: "daily", priority: 1.0 }, + { url: `${SITE_URL}/technology`, changeFrequency: "daily", priority: 0.9 }, + { url: `${SITE_URL}/community`, changeFrequency: "daily", priority: 0.9 }, + { url: `${SITE_URL}/authors`, changeFrequency: "weekly", priority: 0.8 }, + { url: `${SITE_URL}/tag`, changeFrequency: "weekly", priority: 0.8 }, + { url: `${SITE_URL}/search`, changeFrequency: "weekly", priority: 0.6 }, + { url: `${SITE_URL}/community/search`, changeFrequency: "weekly", priority: 0.6 }, +]; + +// minimum posts required per category before the sitemap is considered trustworthy. +// prevents a degraded partial wordpress response from replacing a good cached version. +// override via SITEMAP_MIN_POSTS_PER_CATEGORY — set to 1 in playwright.config.ts so +// playwright fixtures (4 technology / 3 community posts) don't trigger the 503 fallback. +// Only accept explicit positive integers — rejects "", "0", "-1", "abc", "1.5". +// Falls back to 5 so a misconfigured env var cannot silently disable the guard. +const _rawMinPosts = process.env.SITEMAP_MIN_POSTS_PER_CATEGORY?.trim() ?? ""; +const _parsedMinPosts = /^\d+$/.test(_rawMinPosts) ? Number.parseInt(_rawMinPosts, 10) : NaN; +const MIN_POSTS_PER_CATEGORY = + Number.isFinite(_parsedMinPosts) && _parsedMinPosts > 0 ? _parsedMinPosts : 5; + +// maps wordpress category data to the two supported frontend route namespaces. +// matches by both slug and name (lowercased) to handle editorial inconsistencies. +function mapCategoriesToRoutes(categories?: { + edges?: Array<{ node?: { name?: string; slug?: string } }>; +}): CategoryRoute[] { + const routes = new Set(); + + for (const edge of categories?.edges || []) { + const slug = edge?.node?.slug?.trim()?.toLowerCase(); + const name = edge?.node?.name?.trim()?.toLowerCase(); + + if (slug === "technology" || name === "technology") routes.add("technology"); + if (slug === "community" || name === "community") routes.add("community"); + } + + return Array.from(routes); +} + +// converts the getAllPosts() return shape into SitemapPost[]. +// this is the only coupling point between lib/api.ts and lib/sitemap.ts. +// posts with no matching category route are excluded — same rule as before. +export function adaptPostsForSitemap(allPostsResult: { + edges: Array<{ node: any }>; +}): SitemapPost[] { + const posts: SitemapPost[] = []; + + for (const edge of allPostsResult?.edges || []) { + const node = edge?.node; + if (!node?.slug) continue; + + const routes = mapCategoriesToRoutes(node.categories); + if (!routes.length) continue; + + posts.push({ + slug: node.slug, + modified: node.modified, + authorName: node.ppmaAuthorName, + routes, + tags: + node.tags?.edges + ?.map((e: any) => e?.node?.name?.trim()) + .filter((n: unknown): n is string => Boolean(n)) || [], + }); + } + + return posts; +} + +// throws if the crawl looks incomplete, preventing partial data from being cached. +export function assertFullSitemap(posts: SitemapPost[]) { + if (!posts.length) { + throw new Error("Sitemap generation returned zero posts"); + } + + const technologyCount = posts.filter((p) => p.routes.includes("technology")).length; + const communityCount = posts.filter((p) => p.routes.includes("community")).length; + + if (technologyCount < MIN_POSTS_PER_CATEGORY || communityCount < MIN_POSTS_PER_CATEGORY) { + throw new Error( + `Sitemap generation incomplete: technology=${technologyCount}, community=${communityCount} (minimum ${MIN_POSTS_PER_CATEGORY} required per category)` + ); + } +} + +function toIsoDate(value?: string) { + if (!value) return undefined; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString(); +} + +function isRecent(dateValue?: string, days = 30) { + const isoDate = toIsoDate(dateValue); + if (!isoDate) return false; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + return new Date(isoDate) >= cutoff; +} + +// finds the newest modified timestamp across all included posts. +// used to set lastmod on static listing pages so they reflect freshest content. +export function getLatestModified(posts: SitemapPost[]) { + return posts.reduce((latest, post) => { + const current = toIsoDate(post.modified); + if (!current) return latest; + if (!latest || current > latest) return current; + return latest; + }, undefined); +} + +// one entry per post per matching route. +// a post in both technology and community produces two urls. +export function buildPostEntries(posts: SitemapPost[]): SitemapEntry[] { + return posts.flatMap((post) => + post.routes.map((route) => ({ + url: `${SITE_URL}/${route}/${encodeURIComponent(post.slug)}`, + lastModified: toIsoDate(post.modified), + changeFrequency: "weekly" as const, + // posts modified in the last 30 days get higher priority + priority: isRecent(post.modified) ? 0.8 : 0.5, + })) + ); +} + +// one entry per unique author derived from included posts. +// lastmod is the newest modification time of any post by that author. +export function buildAuthorEntries(posts: SitemapPost[]): SitemapEntry[] { + const authorMap = new Map(); + + for (const post of posts) { + const authorName = post.authorName?.trim(); + if (!authorName) continue; + + const authorSlug = sanitizeAuthorSlug(authorName); + if (!authorSlug) continue; + + const currentModified = toIsoDate(post.modified); + const existingModified = authorMap.get(authorSlug); + + if (!existingModified || (currentModified && currentModified > existingModified)) { + authorMap.set(authorSlug, currentModified); + } + } + + return Array.from(authorMap.entries()).map(([authorSlug, lastModified]) => ({ + url: `${SITE_URL}/authors/${authorSlug}`, + lastModified, + changeFrequency: "weekly" as const, + priority: 0.7, + })); +} + +// one entry per unique tag derived from included posts. +// lastmod is the newest modification time of any post with that tag. +export function buildTagEntries(posts: SitemapPost[]): SitemapEntry[] { + const tagMap = new Map(); + + for (const post of posts) { + const postModified = toIsoDate(post.modified); + + for (const tagName of post.tags) { + const normalizedTag = tagName.trim(); + if (!normalizedTag) continue; + + const existingModified = tagMap.get(normalizedTag); + if (!existingModified || (postModified && postModified > existingModified)) { + tagMap.set(normalizedTag, postModified); + } + } + } + + return Array.from(tagMap.entries()).flatMap(([tagName, lastModified]) => { + const tagSlug = sanitizeStringForURL(tagName); + if (!tagSlug) return []; + return [{ + url: `${SITE_URL}/tag/${tagSlug}`, + lastModified, + changeFrequency: "weekly" as const, + priority: 0.7, + }]; + }); +} + +// keeps one entry per url — last writer wins if two paths produce the same url. +export function dedupeEntries(entries: SitemapEntry[]): SitemapEntry[] { + return Array.from(new Map(entries.map((e) => [e.url, e])).values()); +} + +// serializes sitemap entries to xml manually — no external library. +// all values are escaped. priority is formatted to one decimal place. +export function serializeSitemap(entries: SitemapEntry[]): string { + const body = entries + .map((entry) => { + const parts = [ + `${escapeXml(entry.url)}`, + entry.lastModified ? `${escapeXml(entry.lastModified)}` : "", + entry.changeFrequency ? `${entry.changeFrequency}` : "", + typeof entry.priority === "number" + ? `${entry.priority.toFixed(1)}` + : "", + ].filter(Boolean); + + return `${parts.join("")}`; + }) + .join(""); + + return ( + `` + + `${body}` + ); +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + +// last-resort xml served when generation fails with no cached version available. +// contains only the 7 hardcoded static routes — no wordpress data required. +export function getStaticFallbackXml(): string { + const now = new Date().toISOString(); + return serializeSitemap( + STATIC_ROUTES.map((route) => ({ ...route, lastModified: now })) + ); +} diff --git a/next.config.js b/next.config.js index 38bdc6b9..ccb795a8 100644 --- a/next.config.js +++ b/next.config.js @@ -53,7 +53,9 @@ module.exports = { async headers() { return [ { - source: '/(.*)', + // exclude sitemap.xml — xml documents do not use csp and vercel.json + // also excludes it, so keep both layers consistent. + source: '/((?!sitemap\\.xml$).*)', headers: [ { key: 'Content-Security-Policy', diff --git a/package-lock.json b/package-lock.json index 8306b6b6..6bd5da1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.0.1", + "server-only": "^0.0.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "typescript": "^4.7.4" @@ -5476,6 +5477,12 @@ "node": ">=10" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", diff --git a/package.json b/package.json index 67e430fa..187f35b2 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.0.1", + "server-only": "^0.0.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "typescript": "^4.7.4" diff --git a/pages/api/cron/refresh-sitemap.ts b/pages/api/cron/refresh-sitemap.ts new file mode 100644 index 00000000..688e3a2c --- /dev/null +++ b/pages/api/cron/refresh-sitemap.ts @@ -0,0 +1,73 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { + isSearchConsoleSubmissionConfigured, + submitSitemapToSearchConsole, +} from "../../../lib/google-search-console"; + +// GSC submission is fast — no WordPress crawl happens here anymore. +// Sitemap generation is handled by ISR in app/sitemap.xml/route.ts. +export const config = { maxDuration: 30 }; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Cron responses must never be cached — prevent any proxy or CDN from + // serving a stale 401/200 on subsequent Vercel cron invocations. + res.setHeader("Cache-Control", "no-store"); + + const expectedSecret = process.env.CRON_SECRET; + + // distinguish a deployment misconfiguration (500) from a wrong token (401). + if (!expectedSecret) { + console.error( + "CRON_SECRET is not configured. Set it in Vercel environment variables and redeploy." + ); + return res.status(500).json({ + ok: false, + message: "Server misconfiguration — CRON_SECRET is not configured", + }); + } + + // Auth is checked before method to avoid leaking valid HTTP methods to + // unauthenticated callers. + // + // Vercel's cron scheduler automatically adds "Authorization: Bearer " + // to every scheduled invocation — no manual header configuration is needed. + // See: https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs + if (req.headers.authorization !== `Bearer ${expectedSecret}`) { + return res.status(401).json({ ok: false, message: "Unauthorized" }); + } + + if (req.method !== "GET") { + res.setHeader("Allow", "GET"); + return res.status(405).json({ ok: false, message: "Method not allowed" }); + } + + // return early if google search console env vars are not all configured. + if (!isSearchConsoleSubmissionConfigured()) { + return res.status(200).json({ + ok: true, + message: "Google Search Console submission is not configured — skipped", + }); + } + + try { + // notify google that the sitemap has been updated so it re-crawls it. + // the sitemap itself is generated and cached by ISR — no crawl needed here. + const result = await submitSitemapToSearchConsole(); + return res.status(200).json({ ok: true, ...result }); + } catch (error) { + console.error( + "Google Search Console sitemap submission failed. " + + "Verify GOOGLE_SERVICE_ACCOUNT_EMAIL, GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY, " + + "GOOGLE_SEARCH_CONSOLE_SITE_URL, and Search Console property access for the service account.", + error + ); + // Best-effort: sitemap generation/serving is independent of GSC availability. + // Return 200 so the cron job doesn't flap/alert; logs still preserve the failure signal. + return res.status(200).json({ + ok: false, + message: + "Google Search Console submission failed (best-effort). " + + (error instanceof Error ? error.message : "Review server logs and configuration."), + }); + } +} diff --git a/pages/authors/[slug].tsx b/pages/authors/[slug].tsx index 67609d52..3a30209f 100644 --- a/pages/authors/[slug].tsx +++ b/pages/authors/[slug].tsx @@ -111,7 +111,7 @@ export const getStaticProps: GetStaticProps = async ({ candidateAuthorNames.add(slugWords[0]); } - let filteredPosts = []; + let filteredPosts: Awaited>["edges"] = []; for (const candidate of Array.from(candidateAuthorNames)) { if (!candidate) continue; diff --git a/pages/community/[slug].tsx b/pages/community/[slug].tsx index c83d6534..9710e917 100644 --- a/pages/community/[slug].tsx +++ b/pages/community/[slug].tsx @@ -85,7 +85,7 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { }, ]; - const postBodyRef = useRef(); + const postBodyRef = useRef(null); const readProgress = useSpringValue(0); useScroll({ onChange(v) { @@ -148,7 +148,7 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { const safeDescription = getSafeDescription(router.isFallback, post?.seo?.metaDesc, safeTitle); const postUrl = post?.slug ? `${SITE_URL}/community/${post.slug}` : `${SITE_URL}/community`; - const structuredData = []; + const structuredData: Array | ReturnType> = []; if (post?.slug) { structuredData.push( getBreadcrumbListSchema([ diff --git a/pages/technology/[slug].tsx b/pages/technology/[slug].tsx index 44413bb4..a257301d 100644 --- a/pages/technology/[slug].tsx +++ b/pages/technology/[slug].tsx @@ -81,7 +81,7 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { description: reviewAuthorDescription || "A Reviewer for keploy's blog", }, ]; - const postBodyRef = useRef(); + const postBodyRef = useRef(null); const readProgress = useSpringValue(0); useScroll({ onChange(v) { @@ -138,7 +138,7 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { const safeDescription = getSafeDescription(router.isFallback, post?.seo?.metaDesc, safeTitle); const postUrl = post?.slug ? `${SITE_URL}/technology/${post.slug}` : `${SITE_URL}/technology`; - const structuredData = []; + const structuredData: Array | ReturnType> = []; if (post?.slug) { structuredData.push( getBreadcrumbListSchema([ diff --git a/playwright.config.ts b/playwright.config.ts index 237f3b15..135d562c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,8 +17,47 @@ if (fs.existsSync(envTestPath)) { ); } -const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/blog'; -const GRAPHQL_API_URL = process.env.PLAYWRIGHT_GRAPHQL_URL || 'http://localhost:4000/graphql'; +function normalizeLocalhostUrl(value: string) { + const url = new URL(value); + // Playwright and Node may resolve "localhost" to IPv6 (::1) first. + // Bind and request consistently over IPv4 so webServer readiness checks never flake. + if (url.hostname === 'localhost') url.hostname = '127.0.0.1'; + return url; +} + +const rawBaseUrl = process.env.BASE_URL || 'http://localhost:3000/blog'; +const rawGraphqlUrl = process.env.PLAYWRIGHT_GRAPHQL_URL || 'http://localhost:4000/graphql'; + +const baseUrl = normalizeLocalhostUrl(rawBaseUrl); +const graphqlUrl = normalizeLocalhostUrl(rawGraphqlUrl); + +// webServer spawns local processes, so we require an explicit port (or default one +// for local loopback) to avoid mismatches between the URL being tested and the +// port Next.js is started on. +if (!baseUrl.port) { + if (baseUrl.hostname === '127.0.0.1') baseUrl.port = '3000'; + else { + throw new Error( + `BASE_URL must include an explicit port when running Playwright locally (got "${rawBaseUrl}"). ` + + `Example: "http://127.0.0.1:3000/blog".` + ); + } +} + +if (!graphqlUrl.port) { + if (graphqlUrl.hostname === '127.0.0.1') graphqlUrl.port = '4000'; + else { + throw new Error( + `PLAYWRIGHT_GRAPHQL_URL must include an explicit port when running Playwright locally (got "${rawGraphqlUrl}"). ` + + `Example: "http://127.0.0.1:4000/graphql".` + ); + } +} + +const BASE_URL = baseUrl.toString(); +const GRAPHQL_API_URL = graphqlUrl.toString(); +const BASE_PORT = Number.parseInt(baseUrl.port, 10); +const BASE_HOST = baseUrl.hostname; /** * See https://playwright.dev/docs/test-configuration. @@ -107,15 +146,26 @@ export default defineConfig({ stderr: 'pipe', }, { - command: 'npm run build && npm start', + // E2E runs against Next dev server so it can run in sandboxed environments + // where `next start` may be blocked from binding to a port. + command: `next dev -p ${BASE_PORT} -H ${BASE_HOST}`, url: BASE_URL, reuseExistingServer: !process.env.CI, timeout: process.env.CI ? 180000 : 120000, - stdout: 'ignore', + stdout: 'pipe', stderr: 'pipe', env: { WORDPRESS_API_URL: GRAPHQL_API_URL, NEXT_PUBLIC_WORDPRESS_API_URL: GRAPHQL_API_URL, + CRON_SECRET: 'test-secret', + // Make the cron tests deterministic even if the runner machine has these set. + GOOGLE_SERVICE_ACCOUNT_EMAIL: '', + GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: '', + GOOGLE_SEARCH_CONSOLE_SITE_URL: '', + SITEMAP_PUBLIC_URL: '', + // Playwright fixtures have fewer posts than production — lower the + // assertFullSitemap threshold so the ISR route returns 200, not 503. + SITEMAP_MIN_POSTS_PER_CATEGORY: '1', }, }, ], diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index 45fa21f6..00000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,1571 +0,0 @@ - - - - - - https://keploy.io/blog - 2024-03-07T09:25:36+00:00 - 1.00 - - - https://keploy.io/blog/technology - 2024-03-07T09:25:36+00:00 - 0.80 - - - https://keploy.io/blog/community - 2024-03-07T09:25:36+00:00 - 0.80 - - - https://keploy.io/blog/technology/mongodb-in-mock-mode-acting-the-server-part - 2024-03-07T09:25:36+00:00 - 0.80 - - - https://keploy.io/blog/technology/capture-grpc-traffic-going-out-from-a-server - 2024-03-07T09:25:36+00:00 - 0.80 - - - https://keploy.io/blog/technology/integration-vs-e2e-testing-what-worked-for-me-as-a-charm - 2024-03-07T09:25:36+00:00 - 0.80 - - - https://keploy.io/blog/community/canary-testing-a-comprehensive-guide-for-developers - 2024-03-07T09:25:36+00:00 - 0.80 - - - https://keploy.io/blog/community/mock-vs-stub-vs-fake-understand-the-difference - 2024-03-07T09:25:36+00:00 - 0.80 - - - https://keploy.io/blog/community/writing-test-cases-for-cron-jobs-testing - 2024-03-07T09:25:36+00:00 - 0.80 - - - https://keploy.io/blog/technology/automated-e2e-tests-using-property-based-testing-part-ii - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/technology/automated-end-to-end-tests-using-property-based-testing-part-i - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/technology/go-mocks-and-stubs-made-easy - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/understand-the-role-of-continuous-testing-in-ci-cd - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/understanding-testing-in-production - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/5-unit-testing-tools-you-must-know-in-2024 - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/securing-data-protocols-tls-application - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/demystifying-cron-job-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/building-custom-yaml-dsl-in-python - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/what-is-service-mesh - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/understanding-condition-coverage-in-software-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/why-do-i-need-a-unit-testing-tool - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/revolutionizing-software-testing-with-feature-flags - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/all-about-system-integration-testing-in-software-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/bdd-testing-with-cucumber - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/how-to-choose-your-api-performance-testing-tool-a-guide - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/dignify-your-test-automation-with-concise-code-documentation - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/how-to-do-java-unit-testing-effectively - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/performance-testing-guide-to-ensure-your-software-performs-at-its-best - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/top-5-cypress-alternatives-for-web-testing-and-automation - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/what-is-quality-engineering-software - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/functional-testing-unveiling-types-and-real-world-applications - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/understanding-branch-coverage-in-software-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/creating-the-balance-between-end-to-end-and-unit-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/understanding-code-coverage-in-software-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/decoding-http2-traffic-is-hard-but-ebpf-can-help - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/testng-vs-junit-performance-ease-of-use-and-flexibility-compared - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/exploring-the-effectiveness-of-e2e-testing-in-comparison-with-integration-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/understanding-statement-coverage-in-software-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/why-i-love-end-to-end-e2e-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/how-to-generate-test-cases-with-automation-tools - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/decoding-brd-a-devs-guide-to-functional-and-non-functional-requirements-in-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/testing-in-production-with-keploy - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/mastering-test-coverage-quality-over-quantity-in-software-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/all-about-api-testing-keploy - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/exploring-end-to-end-testing-with-ai - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/ai-powered-testing-in-production-revolutionizing-software-stability - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/what-is-the-difference-between-uat-and-e2e-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/software-development-phases - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/testing-nirvana-unveiled-what-why-and-how-in-development - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/what-problem-keploy-solves - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/getting-started-with-keploy - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/mastering-api-test-automation-best-practices-and-tools - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/why-more-end-to-end-testing-is-often-good-enough-for-less-stress - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/e2e-testing-strategies-handling-edge-cases-while-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/understanding-the-difference-between-test-scenarios-and-test-cases - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/qa-automation-engineers-overcoming-testing-limitations - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/code-integrity-explained-building-trust-in-software - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/testing-with-chatgpt-epic-wins-and-fails - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/a-guide-for-observing-go-process-with-ebpf - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/stubs-mocks-fakes-lets-define-the-boundaries - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/e2e-testing-or-unit-testing-difference - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/4-ways-to-accelerate-your-software-testing-life-cycle - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/using-ebpf-for-tracing-go-function-arguments-in-production - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/my-journey-of-devrel-cohort-at-keploy - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/building-a-crud-application-from-scratch-using-golang - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/exploring-graphql-api-development - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/diverse-test-data-boosting-regression-testing-efficiency - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/writing-a-potions-bank-rest-api-with-spring-boot-mongodb - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/my-journey-of-automating-test-cases - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/a-guide-to-various-api-architectures - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/postman-features-that-will-help-you-on-your-journey - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/api-automation-testing-pynt-keploy - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/fun-facts-about-apis - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/know-about-record-and-replay-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/the-game-of-shadow-testing-the-core-of-test-generation - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/apis-vs-webhooks-make-a-github-webhook - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/how-to-secure-your-apis-and-protect-sensitive-data - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/soap-vs-rest-choosing-the-right-api-protocol - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/simplifying-junit-test-stubs-and-mocking - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/terminologies-around-api - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/my-keploy-api-fellowship-journey-2 - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/what-is-unit-testing-anyways - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/what-is-end-to-end-testing-and-why-do-you-need-it - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/an-introduction-to-api-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/everything-you-need-to-know-about-unit-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/my-journey-of-keploy-fellowship-program - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/how-to-mock-backend-of-selenium-tests-using-keploy - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/how-to-do-frontend-test-automation-using-selenium - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/types-of-apis-and-api-architecture - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/introduction-to-testing-with-mocha-keploy - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/my-keploy-api-fellowship-journey - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/teleport-into-tech-space-through-api-gateways - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/frustrations-of-api-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/difficulties-of-api-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/swagger-design-and-document-your-apis - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/history-of-apis - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/understanding-http-and-https-as-a-beginner - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/understanding-the-components-of-apis - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/devrel-at-keploy-experience - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/community/how-did-i-get-to-know-about-apis - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/authors/Ritik%20Jain - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/go - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/http - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/mongodb - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/proxy-server - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/authors/Mehfooz - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/golang - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/grpc - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/authors/Sarthak%20Shyngle - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/api-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/automation-testing - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/e2e - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/integration-test - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/testgpt - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/authors/Animesh%20Pathak - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/canary - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/Feature%20Flags - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/authors/Arindam - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/ai-tools - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/mocks - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/stub - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/ai%20tool - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/cron-job - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/cronjobs - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/tag/keploy - 2024-03-07T09:25:36+00:00 - 0.64 - - - https://keploy.io/blog/authors/charan - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/apis - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/chatgpt - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/openai - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/test-automation - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/tdd - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Jain - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/gomock - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/test-generator - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/unit-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Prajwal - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/ci-cd - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/cicd - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/continuous-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/development - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/devops - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/testing-in-production - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/test-coverage - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/unit%20testing%20tool - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Shivam - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/https - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/networking - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/protocols - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/redis - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/tls - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/cronjob - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/code - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/python - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/yaml-dsl - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/community/ebpf-service-mesh-and-sidecar - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/ebpf - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/service%20mesh - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/sidecar - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/condition%20coverage - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/software-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/test-coverage-in-software-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/tvisha - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/junit - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/software-development - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/system%20integration - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/bdd%20test - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/cucumber%20js - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/performance - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/testing-tool - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/automation - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/documentation - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/java - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/java%20unit%20testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/performance%20testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/cypress%20alternative - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/katalon - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/developers - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/engineering - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/learning - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/quality-assurance - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/software-engineering - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/bdd - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/cross-browser-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/ecommerce - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/functional-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/security - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/branchcoverage - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/e2e%20testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/end%20to%20end%20test - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/coding - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/deployment - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/http2 - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/wireshark - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Pranshu%20Srivastava - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/maven - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/testing-library - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Shashwat - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/end-to-end-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/integration-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/software-development-life-cyclesdlc - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/backend-developments - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/statement-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/community/end-to-end-testing-and-why-do-you-need-it - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/banking - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/developers-mindset - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/gps - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/jest - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/postman - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/business-requirement-document - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/functional-requirement - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/functional-vs-non-functional-requirements - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/non-functional-requirements - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/prashant - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/ai - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/ai-based-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/test-coverage-in-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Arindam,%20Neha - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/web-development - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/wemakedevs - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/developer-tools - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Aditya%20Tomar - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/e2etesting - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/uat-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Harshit%20Paneri - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/software-development-phases - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/community/strategies-handling-edge-cases-e2e-tests - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/continuous-deployment - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/continuous-integration - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/software-quality-assurance - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/testing-nirvana - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/api-automation - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/best-practices - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/mocking - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/test - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Shashwat%20Gupta - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/edge-cases - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/test-driven-development - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/testing-tools - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/end-to-end-tests - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/qa-automation-engineers - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/test-scenarios - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/testing-limitations - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/code-review - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/software - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Neha%20Gupta - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/generative-ai - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/observability - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/opensource - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/fakes - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/stubs - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/test-doubles - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Shivang%20Shandilya - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/docker - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/docker-compose - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/postgresql - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/rest-api - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/graphql - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/ai-test-generation - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/data-generator - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/regression-test-suite - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/regression-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/test-data-management - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/springboot - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Nishant%20Mishra - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/api - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Aditya%20Singh - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/api-architecture - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/api-basics - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Sejal%20Jain - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Ankit%20Kumar - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Hardik%20kumar - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/databases - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/github - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/webhooks - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Jyotirmoy%20Roy - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/soap-api - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Sanskriti%20Harmukh - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/mockito - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Barkatul%20Mujauddin - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Yash%20Saxena - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Zoheb%20Ahmed - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/methodology-and-types-of-software-testing - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Priya%20Srivastava - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Diganta%20Kr%20Banik - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/api-testing-tools - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Krupesh%20Vithlani - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/app-development - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/developer - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/technology - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Ankit%20Kumar,%20Animesh%20Pathak - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/backend - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/frontend-development - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/selenium - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/KANISHAK%20CHAURASIA - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/keploy-api-fellowship - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Pradhyuman%20Sharma - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/javascript - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/authors/Harsh%20Rastogi - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/devrel - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/api-gateway - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/microservices - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/monoliths - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/osi - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/api-development - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/frustration - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/swagger - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/history - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/ssl - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/internship - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/startup - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/technical-writing-1 - 2024-03-07T09:25:36+00:00 - 0.51 - - - https://keploy.io/blog/tag/beginners - 2024-03-07T09:25:36+00:00 - 0.51 - - - \ No newline at end of file diff --git a/scripts/submit-sitemap-to-search-console.mjs b/scripts/submit-sitemap-to-search-console.mjs new file mode 100644 index 00000000..24bb2ca1 --- /dev/null +++ b/scripts/submit-sitemap-to-search-console.mjs @@ -0,0 +1,160 @@ +import crypto from "crypto"; +import dotenv from "dotenv"; + +// load .env.local explicitly so this standalone script behaves like the local app +// and can reuse the same credentials without extra shell setup. +dotenv.config({ path: ".env.local" }); + +const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_WEBMASTERS_SCOPE = "https://www.googleapis.com/auth/webmasters"; +const GOOGLE_SITEMAPS_SUBMIT_BASE_URL = "https://www.googleapis.com/webmasters/v3/sites"; +const GOOGLE_TOKEN_LIFETIME_SECONDS = 3600; +const GOOGLE_FETCH_TIMEOUT_MS = 25000; +const DEFAULT_SITEMAP_URL = "https://keploy.io/blog/sitemap.xml"; + +function getRequiredEnv(name) { + // fail early if any required local env var is missing. + const value = process.env[name]?.trim(); + + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +function base64UrlEncode(value) { + // convert to jwt-safe base64url form. + return Buffer.from(value) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function getGooglePrivateKey() { + // restore newline characters so the pem key is valid for crypto signing. + return getRequiredEnv("GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY").replace(/\\n/g, "\n"); +} + +function createServiceAccountJwt() { + const clientEmail = getRequiredEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"); + const now = Math.floor(Date.now() / 1000); + + // construct the service-account jwt the same way the app code does so this + // script is a faithful local verification path. + const header = { + alg: "RS256", + typ: "JWT", + }; + + const payload = { + iss: clientEmail, + scope: GOOGLE_WEBMASTERS_SCOPE, + aud: GOOGLE_OAUTH_TOKEN_URL, + exp: now + GOOGLE_TOKEN_LIFETIME_SECONDS, + iat: now, + }; + + const encodedHeader = base64UrlEncode(JSON.stringify(header)); + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + const unsignedToken = `${encodedHeader}.${encodedPayload}`; + + const signer = crypto.createSign("RSA-SHA256"); + signer.update(unsignedToken); + signer.end(); + + const signature = signer.sign(getGooglePrivateKey()); + + return `${unsignedToken}.${base64UrlEncode(signature)}`; +} + +async function fetchGoogleAccessToken() { + // exchange the signed jwt for a short-lived oauth token. + const assertion = createServiceAccountJwt(); + + const response = await fetch(GOOGLE_OAUTH_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion, + }), + signal: AbortSignal.timeout(GOOGLE_FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + throw new Error( + `Google OAuth token request failed: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : "" + }` + ); + } + + const json = await response.json(); + + if (!json.access_token) { + throw new Error("Google OAuth token response did not include an access token"); + } + + return json.access_token; +} + +async function submitSitemapToSearchConsole() { + // get the token, the property id, and the sitemap url from local env. + const accessToken = await fetchGoogleAccessToken(); + const siteUrl = getRequiredEnv("GOOGLE_SEARCH_CONSOLE_SITE_URL"); + const sitemapUrl = process.env.SITEMAP_PUBLIC_URL?.trim() || DEFAULT_SITEMAP_URL; + + // submit the sitemap url directly to google search console so we can verify + // the credentials and property access without going through the cron route. + const response = await fetch( + `${GOOGLE_SITEMAPS_SUBMIT_BASE_URL}/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent( + sitemapUrl + )}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + signal: AbortSignal.timeout(GOOGLE_FETCH_TIMEOUT_MS), + } + ); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + throw new Error( + `Google Search Console sitemap submission failed: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : "" + }` + ); + } + + return { + ok: true, + siteUrl, + sitemapUrl, + submittedAt: new Date().toISOString(), + }; +} + +try { + // print a compact success object so the local test result is easy to inspect. + const result = await submitSitemapToSearchConsole(); + console.log(JSON.stringify(result, null, 2)); +} catch (error) { + // print a compact structured failure object for quick debugging. + console.error( + JSON.stringify( + { + ok: false, + message: error instanceof Error ? error.message : String(error), + }, + null, + 2 + ) + ); + process.exit(1); +} + diff --git a/tests/e2e/RefreshSitemapCron.spec.ts b/tests/e2e/RefreshSitemapCron.spec.ts new file mode 100644 index 00000000..2d97f50b --- /dev/null +++ b/tests/e2e/RefreshSitemapCron.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test'; + +// CRON_SECRET is injected as 'test-secret' via playwright.config.ts webServer env. +// These tests call the Pages API route directly using the Playwright request fixture. + +test.describe('Refresh Sitemap Cron API', () => { + test('returns 401 when Authorization header is missing', async ({ request, baseURL }) => { + const response = await request.get(`${baseURL}/api/cron/refresh-sitemap`); + expect(response.status()).toBe(401); + + const body = await response.json(); + expect(body.ok).toBe(false); + }); + + test('returns 401 when Authorization header has wrong secret', async ({ request, baseURL }) => { + const response = await request.get(`${baseURL}/api/cron/refresh-sitemap`, { + headers: { Authorization: 'Bearer wrong-secret' }, + }); + expect(response.status()).toBe(401); + + const body = await response.json(); + expect(body.ok).toBe(false); + }); + + test('returns 405 for POST with valid Authorization', async ({ request, baseURL }) => { + // Auth is checked before method guard — valid token must be used to reach the method check + const response = await request.post(`${baseURL}/api/cron/refresh-sitemap`, { + headers: { Authorization: 'Bearer test-secret' }, + }); + expect(response.status()).toBe(405); + expect(response.headers()['allow']).toBe('GET'); + + const body = await response.json(); + expect(body.ok).toBe(false); + }); + + test('returns 200 for authorized GET when GSC is not configured', async ({ request, baseURL }) => { + // In the test environment Google Search Console env vars are not set, + // so the handler skips GSC submission and returns a "skipped" success. + const response = await request.get(`${baseURL}/api/cron/refresh-sitemap`, { + headers: { Authorization: 'Bearer test-secret' }, + }); + expect(response.status()).toBe(200); + + const body = await response.json(); + expect(body.ok).toBe(true); + // GSC not configured in test env — handler returns a message, not siteUrl/sitemapUrl + expect(typeof body.message).toBe('string'); + }); + + test('unauthenticated callers get 401, not 405 — method not leaked', async ({ request, baseURL }) => { + // Security: method guard must not run before auth check so that + // unauthenticated callers cannot probe which HTTP methods are valid. + const response = await request.post(`${baseURL}/api/cron/refresh-sitemap`); + expect(response.status()).toBe(401); + }); + + test('Cache-Control header prevents caching of cron response', async ({ request, baseURL }) => { + // Cache-Control: no-store is set by the handler itself (res.setHeader in refresh-sitemap.ts). + // vercel.json also enforces it on /blog/api/* routes in production, but in local/CI + // Playwright runs against the Next.js dev server where vercel.json headers are not applied. + const response = await request.get(`${baseURL}/api/cron/refresh-sitemap`, { + headers: { Authorization: 'Bearer test-secret' }, + }); + const cc = response.headers()['cache-control'] ?? ''; + expect(cc).toContain('no-store'); + }); +}); diff --git a/tests/e2e/Sitemap.spec.ts b/tests/e2e/Sitemap.spec.ts new file mode 100644 index 00000000..82154684 --- /dev/null +++ b/tests/e2e/Sitemap.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Sitemap ISR Route', () => { + test('/sitemap.xml returns 200 with correct Content-Type', async ({ request, baseURL }) => { + const response = await request.get(`${baseURL}/sitemap.xml`); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('application/xml'); + }); + + test('/sitemap.xml has correct ISR Cache-Control headers', async ({ request, baseURL }) => { + const response = await request.get(`${baseURL}/sitemap.xml`); + const cc = response.headers()['cache-control'] ?? ''; + // ISR: CDN caches 1h, browsers always revalidate, stale-while-revalidate 1h + expect(cc).toContain('s-maxage=3600'); + expect(cc).toContain('max-age=0'); + expect(cc).toContain('stale-while-revalidate=3600'); + }); + + test('/sitemap.xml contains valid XML sitemap structure', async ({ request, baseURL }) => { + const response = await request.get(`${baseURL}/sitemap.xml`); + const xml = await response.text(); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + }); + + test('/sitemap.xml includes all static routes', async ({ request, baseURL }) => { + const response = await request.get(`${baseURL}/sitemap.xml`); + const xml = await response.text(); + expect(xml).toContain('https://keploy.io/blog'); + expect(xml).toContain('https://keploy.io/blog/technology'); + expect(xml).toContain('https://keploy.io/blog/community'); + }); + + test('/sitemap.xml contains dynamic post URLs from WordPress', async ({ request, baseURL }) => { + const response = await request.get(`${baseURL}/sitemap.xml`); + const xml = await response.text(); + // Verify at least one dynamic post URL exists under /technology or /community. + // A count-only check passes even on the static fallback (7 routes); asserting a + // pattern match proves the WordPress fixture data was actually included. + const locs = Array.from(xml.matchAll(/(.*?)<\/loc>/g)).map(m => m[1]); + const hasDynamicPostUrl = locs.some(loc => + /^https:\/\/keploy\.io\/blog\/(technology|community)\/[^/]+$/.test(loc), + ); + expect(hasDynamicPostUrl).toBe(true); + }); + + test('/sitemap.xml entries have dates', async ({ request, baseURL }) => { + const response = await request.get(`${baseURL}/sitemap.xml`); + const xml = await response.text(); + // Every block should include a + expect(xml).toContain(''); + // lastmod should be a valid ISO date fragment (YYYY-MM-DD) + expect(xml).toMatch(/\d{4}-\d{2}-\d{2}/); + }); + + test('/sitemap.xml entries have ', async ({ request, baseURL }) => { + const response = await request.get(`${baseURL}/sitemap.xml`); + const xml = await response.text(); + expect(xml).toContain(''); + }); + + test('sitemap.xml is not served with a Content-Security-Policy header', async ({ request, baseURL }) => { + const response = await request.get(`${baseURL}/sitemap.xml`); + // CSP is intentionally excluded from sitemap (XML parsers do not understand it) + const csp = response.headers()['content-security-policy']; + expect(csp).toBeUndefined(); + }); + + test('/sitemap.xml URLs are deduplicated', async ({ request, baseURL }) => { + const response = await request.get(`${baseURL}/sitemap.xml`); + const xml = await response.text(); + const locs = Array.from(xml.matchAll(/(.*?)<\/loc>/g)).map(m => m[1]); + const unique = new Set(locs); + expect(unique.size).toBe(locs.length); + }); +}); diff --git a/tests/mock-server.js b/tests/mock-server.js index 11fbb67b..fcf615a4 100644 --- a/tests/mock-server.js +++ b/tests/mock-server.js @@ -249,6 +249,22 @@ function handleGraphQL(body) { return { data: { post: { databaseId: 1, slug: 'mock-post', status: 'publish' } } }; } + // AllPostsForSitemap must be checked before the generic AllPosts guard below. + // It needs posts from both categories so assertFullSitemap() doesn't throw + // in the test environment (communityCount must be ≥ SITEMAP_MIN_POSTS_PER_CATEGORY). + if (query.includes('AllPostsForSitemap')) { + const techEdges = technologyPosts.data.posts.edges; + const commEdges = communityPosts.data.posts.edges; + return { + data: { + posts: { + edges: [...techEdges, ...commEdges], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + } + if (query.includes('AllPosts')) { return technologyPosts; } diff --git a/tests/pages/NotFoundPage.spec.ts b/tests/pages/NotFoundPage.spec.ts index ce999b8d..211eaec7 100644 --- a/tests/pages/NotFoundPage.spec.ts +++ b/tests/pages/NotFoundPage.spec.ts @@ -15,16 +15,6 @@ test.describe('404 Not Found Page - Component Availability', () => { await expect(notFoundText.first()).toBeVisible(); }); - test('should render the Navigation component', async ({ page }) => { - const nav = page.locator('nav').first(); - await expect(nav).toBeVisible(); - }); - - test('should render the Keploy logo in Navigation', async ({ page }) => { - const headerLogo = page.locator('header img[alt="Keploy Logo"]').first(); - await expect(headerLogo).toBeVisible(); - }); - test('should render navigation links back to main sections', async ({ page }) => { const navLinks = page.locator('a[href*="/technology"], a[href*="/community"], a[href="/"], a[href="/blog/"]'); const count = await navLinks.count(); diff --git a/tsconfig.json b/tsconfig.json index 1e626d7a..a2983b39 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": false, @@ -16,9 +20,24 @@ "incremental": true, "baseUrl": ".", "paths": { - "@/*": ["./*"] - } + "@/*": [ + "./*" + ] + }, + "plugins": [ + { + "name": "next" + } + ], + "strictNullChecks": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/vercel.json b/vercel.json index ab5cb8be..0bd134ff 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,10 @@ { + "crons": [ + { + "path": "/blog/api/cron/refresh-sitemap", + "schedule": "0 0 * * *" + } + ], "redirects": [ { "source": "/blog/community/everything-you-need-to-know-about-api-testing", @@ -41,7 +47,7 @@ ] }, { - "source": "/blog/(.*)", + "source": "/blog/((?!(?:sitemap\\.xml$|api/|_next/)).*)", "headers": [ { "key": "Content-Security-Policy",