232 lines
7.8 KiB
TypeScript
232 lines
7.8 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
|
|
async function signupAndGetAccessToken(
|
|
request: import("@playwright/test").APIRequestContext,
|
|
email: string,
|
|
password: string,
|
|
): Promise<string> {
|
|
const signupRes = await request.post("/api/auth/signup", {
|
|
headers: { "Content-Type": "application/json" },
|
|
data: { email, password },
|
|
});
|
|
|
|
const signupStatus = signupRes.status();
|
|
if (signupStatus !== 201) {
|
|
// 디버깅용: CI 등에서 500 이 떨어질 때 응답 바디를 함께 로그로 출력해 원인을 파악할 수 있게 한다.
|
|
console.log("[public-seo] signup error status=", signupStatus);
|
|
try {
|
|
const bodyText = await signupRes.text();
|
|
console.log("[public-seo] signup error body=", bodyText);
|
|
} catch (e) {
|
|
console.log("[public-seo] signup error: body read failed", e);
|
|
}
|
|
}
|
|
|
|
expect(signupStatus).toBe(201);
|
|
|
|
const setCookieHeader = signupRes.headers()["set-cookie"];
|
|
expect(setCookieHeader).toBeTruthy();
|
|
|
|
const cookieHeaderString = Array.isArray(setCookieHeader)
|
|
? setCookieHeader.join("; ")
|
|
: (setCookieHeader as string);
|
|
|
|
const match = cookieHeaderString.match(/pb_access=([^;]+)/);
|
|
expect(match).not.toBeNull();
|
|
|
|
const accessToken = match![1];
|
|
return accessToken;
|
|
}
|
|
|
|
async function createProjectWithSnapshot(
|
|
request: import("@playwright/test").APIRequestContext,
|
|
accessToken: string,
|
|
params: { slug: string; title: string; projectConfig: any },
|
|
) {
|
|
const { slug, title, projectConfig } = params;
|
|
|
|
const res = await request.post("/api/projects", {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Cookie: `pb_access=${accessToken}`,
|
|
},
|
|
data: {
|
|
title,
|
|
slug,
|
|
// 새 스펙: contentJson 은 blocks + projectConfig 스냅샷으로 보낸다.
|
|
contentJson: {
|
|
blocks: [],
|
|
projectConfig,
|
|
},
|
|
},
|
|
});
|
|
|
|
const status = res.status();
|
|
if (status !== 201) {
|
|
console.log("[public-seo] createProject error status=", status);
|
|
try {
|
|
const bodyText = await res.text();
|
|
console.log("[public-seo] createProject error body=", bodyText);
|
|
} catch (e) {
|
|
console.log("[public-seo] createProject error: body read failed", e);
|
|
}
|
|
}
|
|
|
|
expect(status).toBe(201);
|
|
}
|
|
|
|
async function patchProjectStatus(
|
|
request: import("@playwright/test").APIRequestContext,
|
|
accessToken: string,
|
|
slug: string,
|
|
status: "DRAFT" | "PUBLISHED" | "ARCHIVED",
|
|
) {
|
|
const res = await request.patch(`/api/projects/${slug}`, {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Cookie: `pb_access=${accessToken}`,
|
|
},
|
|
data: { status },
|
|
});
|
|
|
|
const code = res.status();
|
|
if (code !== 200) {
|
|
console.log("[public-seo] patchProjectStatus error status=", code);
|
|
try {
|
|
const bodyText = await res.text();
|
|
console.log("[public-seo] patchProjectStatus error body=", bodyText);
|
|
} catch (e) {
|
|
console.log("[public-seo] patchProjectStatus error: body read failed", e);
|
|
}
|
|
}
|
|
|
|
expect(code).toBe(200);
|
|
}
|
|
|
|
// 퍼블릭 페이지(/p/[slug])에서 프로젝트 SEO 설정이 실제 <title>/메타 태그/스크립트에 반영되는지 검증하는 E2E.
|
|
// - seoTitle/seoDescription/seoCanonicalUrl/seoOgImageUrl → title 및 OG/Twitter 메타
|
|
// - seoNoIndex=true → <meta name="robots" content="noindex, nofollow"> 존재
|
|
// - seoCanonicalUrl 은 canonical <link> 가 아니라 og:url 로만 노출되어야 한다.
|
|
// - headHtml 은 <head> 에 그대로 삽입되고, trackingScript 는 <body> 에 삽입/실행되어야 한다.
|
|
|
|
test.describe("Public SEO metadata", () => {
|
|
test("PUBLISHED 프로젝트의 SEO 설정이 <title> 및 메타 태그에 반영되어야 한다", async ({ page, request }) => {
|
|
const now = Date.now();
|
|
const email = `public-seo-${now}@example.com`;
|
|
const password = "public-seo-password";
|
|
const slug = `public-seo-${now}`;
|
|
|
|
const accessToken = await signupAndGetAccessToken(request, email, password);
|
|
|
|
const seoTitle = "SEO 타이틀 - My landing";
|
|
const seoDescription = "SEO 설명입니다.";
|
|
const seoUrl = `https://example.com/${slug}`;
|
|
const seoImage = "https://example.com/og-image.png";
|
|
|
|
const projectConfig = {
|
|
title: "에디터 타이틀",
|
|
slug,
|
|
canvasPreset: "full",
|
|
canvasBgColorHex: "#020617",
|
|
bodyBgColorHex: "#020617",
|
|
seoTitle,
|
|
seoDescription,
|
|
seoCanonicalUrl: seoUrl,
|
|
seoOgImageUrl: seoImage,
|
|
seoNoIndex: false,
|
|
headHtml: '<meta name="x-public-head" content="public-seo-test" />',
|
|
trackingScript: "<script>window.__pbPublicSeoTracking = 'ok';</script>",
|
|
};
|
|
|
|
await createProjectWithSnapshot(request, accessToken, {
|
|
slug,
|
|
title: projectConfig.title,
|
|
projectConfig,
|
|
});
|
|
|
|
await patchProjectStatus(request, accessToken, slug, "PUBLISHED");
|
|
|
|
const response = await page.goto(`/p/${slug}`);
|
|
expect(response?.status()).toBe(200);
|
|
|
|
// 1) <title>
|
|
await expect(page).toHaveTitle(seoTitle);
|
|
|
|
// 2) 기본/OG/Twitter 메타 태그
|
|
const metaDesc = page.locator('meta[name="description"]');
|
|
await expect(metaDesc).toHaveAttribute("content", seoDescription);
|
|
|
|
const ogTitle = page.locator('meta[property="og:title"]');
|
|
await expect(ogTitle).toHaveAttribute("content", seoTitle);
|
|
|
|
const ogDesc = page.locator('meta[property="og:description"]');
|
|
await expect(ogDesc).toHaveAttribute("content", seoDescription);
|
|
|
|
const ogUrl = page.locator('meta[property="og:url"]');
|
|
await expect(ogUrl).toHaveAttribute("content", seoUrl);
|
|
|
|
const ogImage = page.locator('meta[property="og:image"]');
|
|
await expect(ogImage).toHaveAttribute("content", seoImage);
|
|
|
|
const twitterTitle = page.locator('meta[name="twitter:title"]');
|
|
await expect(twitterTitle).toHaveAttribute("content", seoTitle);
|
|
|
|
// 3) seoNoIndex=false 인 경우 robots 메타는 없어야 한다.
|
|
const robots = page.locator('meta[name="robots"]');
|
|
await expect(robots).toHaveCount(0);
|
|
|
|
// 4) canonical <link> 는 생성되지 않아야 한다.
|
|
const canonicalLink = page.locator('link[rel="canonical"]');
|
|
await expect(canonicalLink).toHaveCount(0);
|
|
|
|
// 5) headHtml 스니펫이 <head> 에 삽입되어야 한다.
|
|
const customHeadMeta = page.locator('meta[name="x-public-head"]');
|
|
await expect(customHeadMeta).toHaveAttribute("content", "public-seo-test");
|
|
|
|
// 6) trackingScript 가 <body> 에 삽입 및 실행되어 window 플래그를 남겨야 한다.
|
|
const trackingValue = await page.evaluate(() => (window as any).__pbPublicSeoTracking ?? null);
|
|
expect(trackingValue).toBe("ok");
|
|
});
|
|
|
|
test("seoNoIndex=true 인 PUBLISHED 프로젝트는 robots noindex 메타 태그를 포함해야 한다", async ({ page, request }) => {
|
|
const now = Date.now();
|
|
const email = `public-seo-noindex-${now}@example.com`;
|
|
const password = "public-seo-noindex-password";
|
|
const slug = `public-seo-noindex-${now}`;
|
|
|
|
const accessToken = await signupAndGetAccessToken(request, email, password);
|
|
|
|
const seoTitle = "Noindex SEO 타이틀";
|
|
const seoDescription = "Noindex SEO 설명입니다.";
|
|
|
|
const projectConfig = {
|
|
title: "Noindex 프로젝트",
|
|
slug,
|
|
canvasPreset: "full",
|
|
canvasBgColorHex: "#020617",
|
|
bodyBgColorHex: "#020617",
|
|
seoTitle,
|
|
seoDescription,
|
|
seoCanonicalUrl: "",
|
|
seoOgImageUrl: "",
|
|
seoNoIndex: true,
|
|
};
|
|
|
|
await createProjectWithSnapshot(request, accessToken, {
|
|
slug,
|
|
title: projectConfig.title,
|
|
projectConfig,
|
|
});
|
|
|
|
await patchProjectStatus(request, accessToken, slug, "PUBLISHED");
|
|
|
|
const response = await page.goto(`/p/${slug}`);
|
|
expect(response?.status()).toBe(200);
|
|
|
|
await expect(page).toHaveTitle(seoTitle);
|
|
|
|
const robots = page.locator('meta[name="robots"]');
|
|
await expect(robots).toHaveAttribute("content", /noindex/);
|
|
});
|
|
});
|