Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
title: "PHP 9.0 : nouveautés, changements majeurs et impacts à venir"
kind: "news"
date: "2025-05-06"
updatedAt: "2026-04-28"
author: "Florian Chenot"
Expand Down
1 change: 1 addition & 0 deletions content/blog/retour-sur-lafup-day-2023.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
title: "AFUP Day 2023 : retours et conférences PHP"
kind: "news"
date: "2023-05-17"
updatedAt: "2026-03-26"
author: "Efficience IT"
Expand Down
1 change: 1 addition & 0 deletions content/blog/retour-sur-lafup-day-2024.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
title: "Retour sur l'AFUP Day 2024"
kind: "news"
date: "2024-06-05"
updatedAt: "2026-03-26"
author: "Louis-Arnaud Catoire"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
title: "Retour sur l'AFUP Day 2025 Lille : PHP à l'honneur, communauté au cœur"
kind: "news"
date: "2025-05-27"
updatedAt: "2026-03-26"
author: "Louis-Arnaud Catoire"
Expand Down
1 change: 1 addition & 0 deletions content/blog/retour-sur-le-forum-php-2024.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
title: "Forum PHP & SymfonyCon 2024 : retours et tendances clés"
kind: "news"
date: "2024-10-25"
updatedAt: "2026-03-26"
author: "Louis-Arnaud Catoire"
Expand Down
1 change: 1 addition & 0 deletions content/blog/vivatech-2025-linnovation-au-rendez-vous.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
title: "Vivatech 2025 : innovations tech et tendances clés pour le web et l'IA"
kind: "news"
date: "2025-06-26"
updatedAt: "2026-03-26"
author: "Louis-Arnaud Catoire"
Expand Down
144 changes: 144 additions & 0 deletions src/__tests__/app/sitemap-news-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/** @jest-environment jsdom */

import { GET, dynamic } from "@/app/sitemap-news.xml/route";
import { getAllPosts } from "@/lib/blog";
import type { BlogPost } from "@/types/blog";

jest.mock("@/lib/blog", () => ({
getAllPosts: jest.fn(),
}));

function makePost(overrides: Partial<BlogPost>): BlogPost {
return {
slug: "post-test",
title: "Titre test",
date: "2026-05-05T10:00:00.000Z",
author: "Auteur",
category: "Formation",
kind: "blog",
excerpt: "Excerpt",
content: "Contenu",
wordCount: 1200,
...overrides,
};
}

describe("sitemap-news.xml route", () => {
it("uses force-static rendering", () => {
expect(dynamic).toBe("force-static");
});

beforeAll(() => {
class TestResponse {
private readonly body: string;
readonly headers: Headers;

constructor(body: string, init?: { headers?: Record<string, string> }) {
this.body = body;
this.headers = new Headers(init?.headers);
}

async text() {
return this.body;
}
}

Object.defineProperty(globalThis, "Response", {
value: TestResponse,
configurable: true,
writable: true,
});
});

beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-05-05T12:00:00.000Z"));
});

afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});

it("includes only news posts published within 48 hours", async () => {
const mockedGetAllPosts = getAllPosts as jest.MockedFunction<typeof getAllPosts>;
mockedGetAllPosts.mockReturnValue([
makePost({
slug: "news-recent",
title: "News récente",
kind: "news",
date: "2026-05-05T10:00:00.000Z",
}),
makePost({
slug: "news-old",
title: "News ancienne",
kind: "news",
date: "2026-05-02T11:59:59.000Z",
}),
makePost({
slug: "tech-recent",
title: "Tech récente",
kind: "tech",
date: "2026-05-05T10:00:00.000Z",
}),
]);

const response = GET();
const xml = await response.text();

expect(response.headers.get("Content-Type")).toBe("application/xml; charset=utf-8");
expect(xml).toContain("<loc>https://www.itefficience.com/article/news-recent</loc>");
expect(xml).not.toContain("news-old");
expect(xml).not.toContain("tech-recent");
});

it("escapes XML-sensitive characters in titles", async () => {
const mockedGetAllPosts = getAllPosts as jest.MockedFunction<typeof getAllPosts>;
mockedGetAllPosts.mockReturnValue([
makePost({
slug: "news-escaped",
title: `Titre & <test> "quote" 'apostrophe'`,
kind: "news",
date: "2026-05-05T09:00:00.000Z",
}),
]);

const response = GET();
const xml = await response.text();

expect(xml).toContain(
"<news:title>Titre &amp; &lt;test&gt; &quot;quote&quot; &apos;apostrophe&apos;</news:title>",
);
});

it("skips news posts with invalid or future publication dates", async () => {
const mockedGetAllPosts = getAllPosts as jest.MockedFunction<typeof getAllPosts>;
mockedGetAllPosts.mockReturnValue([
makePost({
slug: "news-invalid",
title: "Date invalide",
kind: "news",
date: "not-a-date",
}),
makePost({
slug: "news-future",
title: "Date future",
kind: "news",
date: "2026-05-05T13:00:00.000Z",
}),
makePost({
slug: "news-valid",
title: "Date valide",
kind: "news",
date: "2026-05-05T11:00:00.000Z",
}),
]);

const response = GET();
const xml = await response.text();

expect(xml).toContain("<loc>https://www.itefficience.com/article/news-valid</loc>");
expect(xml).not.toContain("news-invalid");
expect(xml).not.toContain("news-future");
});
});
3 changes: 1 addition & 2 deletions src/__tests__/components/ui/ResponsiveImage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe("ResponsiveImage", () => {
expect(container.querySelector("img")).not.toBeNull();
});

it("falls back to a plain <img> for non-blog sources", () => {
it("falls back to a non-picture image for non-blog sources", () => {
const { container } = render(
<ResponsiveImage
src="/images/illustrations/foo.svg"
Expand All @@ -69,7 +69,6 @@ describe("ResponsiveImage", () => {
expect(container.querySelector("picture")).toBeNull();
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe("/images/illustrations/foo.svg");
expect(img?.getAttribute("sizes")).toBe("200px");
});

it("honors loading=eager and fetchPriority=high when provided", () => {
Expand Down
35 changes: 35 additions & 0 deletions src/__tests__/lib/blog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe("getPostBySlug", () => {
expect(post!.date).toBe("");
expect(post!.author).toBe("");
expect(post!.category).toBe("");
expect(post!.kind).toBe("blog");
expect(post!.excerpt).toBe("");
expect(post!.wordCount).toBe(0);
expect(post!.mainTech).toBeUndefined();
Expand Down Expand Up @@ -94,6 +95,39 @@ describe("getPostBySlug", () => {
existsSpy.mockRestore();
}
});

it("defaults kind to blog when frontmatter is missing kind", () => {
const existsSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true);
const readFileSpy = jest.spyOn(fs, "readFileSync").mockReturnValue("---\ntitle: Test\n---\n" as never);

try {
const post = getPostBySlug(TEMP_SLUG);
expect(post!.kind).toBe("blog");
} finally {
readFileSpy.mockRestore();
existsSpy.mockRestore();
}
});

it("accepts kind news and rejects unknown values", () => {
const existsSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true);
try {
const readFileSpy = jest
.spyOn(fs, "readFileSync")
.mockReturnValue("---\nkind: news\n---\n" as never);
const post = getPostBySlug(TEMP_SLUG);
expect(post!.kind).toBe("news");
readFileSpy.mockRestore();
const readInvalidKindSpy = jest
.spyOn(fs, "readFileSync")
.mockReturnValue("---\nkind: unknown\n---\n" as never);
const fallbackPost = getPostBySlug(TEMP_SLUG);
expect(fallbackPost!.kind).toBe("blog");
readInvalidKindSpy.mockRestore();
} finally {
existsSpy.mockRestore();
}
});
});

describe("getAllPosts", () => {
Expand All @@ -109,6 +143,7 @@ describe("getAllPosts", () => {
expect(tempPost!.date).toBe("");
expect(tempPost!.author).toBe("");
expect(tempPost!.category).toBe("");
expect(tempPost!.kind).toBe("blog");
expect(tempPost!.excerpt).toBe("");
expect(tempPost!.wordCount).toBe(0);
} finally {
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/lib/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function makePost(overrides: Partial<BlogPost> = {}): BlogPost {
content: "content",
wordCount: 100,
...overrides,
kind: overrides.kind ?? "blog",
};
}

Expand Down
32 changes: 28 additions & 4 deletions src/__tests__/lib/structured-data.snapshots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe("structured-data snapshots", () => {
date: "2026-01-01",
author: "Auteur",
category: "Symfony",
kind: "blog",
excerpt: "Excerpt 1",
content: "",
wordCount: 0,
Expand All @@ -105,6 +106,7 @@ describe("structured-data snapshots", () => {
date: "2026-01-02",
author: "Auteur",
category: "DevOps",
kind: "blog",
excerpt: "Excerpt 2",
content: "",
wordCount: 0,
Expand Down Expand Up @@ -172,7 +174,7 @@ describe("structured-data snapshots", () => {
expect(
articleJsonLd({
url: "https://www.itefficience.com/article/test",
isTech: false,
kind: "blog",
title: "Article test",
excerpt: "Un excerpt de test.",
author: {
Expand All @@ -196,7 +198,7 @@ describe("structured-data snapshots", () => {
expect(
articleJsonLd({
url: "https://www.itefficience.com/article/tech",
isTech: true,
kind: "tech",
title: "Tech article",
excerpt: "Tech excerpt.",
author: {
Expand All @@ -217,7 +219,7 @@ describe("structured-data snapshots", () => {
it("articleJsonLd as TechArticle defaults proficiencyLevel to Intermediate", () => {
const result = articleJsonLd({
url: "https://www.itefficience.com/article/tech-default",
isTech: true,
kind: "tech",
title: "Tech default",
excerpt: "Excerpt.",
author: {
Expand All @@ -240,7 +242,7 @@ describe("structured-data snapshots", () => {
it("articleJsonLd without image falls back to undefined", () => {
const result = articleJsonLd({
url: "https://www.itefficience.com/article/no-image",
isTech: false,
kind: "blog",
title: "Sans image",
excerpt: "Pas d'image.",
author: {
Expand All @@ -259,6 +261,28 @@ describe("structured-data snapshots", () => {
expect(result.dateModified).toBe("2024-01-01");
});

it("articleJsonLd as NewsArticle", () => {
const result = articleJsonLd({
url: "https://www.itefficience.com/article/news",
kind: "news",
title: "News article",
excerpt: "News excerpt.",
author: {
"@type": "Person",
name: "Auteur",
jobTitle: "Dev",
url: "https://www.itefficience.com/la-team",
sameAs: [],
},
category: "Formation",
date: "2024-01-01",
wordCount: 500,
timeRequiredMinutes: 3,
});
expect(result["@type"]).toBe("NewsArticle");
expect((result as { proficiencyLevel?: string }).proficiencyLevel).toBeUndefined();
});

it("faqPageJsonLd", () => {
expect(
faqPageJsonLd([
Expand Down
8 changes: 7 additions & 1 deletion src/__tests__/lib/structured-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@
describe("entity linking via mainTech", () => {
const articleInput = {
url: "https://www.itefficience.com/article/test",
isTech: true,
kind: "tech" as const,
title: "Test",
excerpt: "Excerpt",
author: { "@type": "Person" as const, name: "Auteur", url: "https://example.com", jobTitle: "Author", sameAs: [] },
Expand Down Expand Up @@ -214,4 +214,10 @@
const result = articleJsonLd(articleInput);
expect(result.about).toBeUndefined();
});

it("articleJsonLd defaults kind to blog when not provided", () => {
const { kind: _kind, ...inputWithoutKind } = articleInput;

Check warning on line 219 in src/__tests__/lib/structured-data.test.ts

View workflow job for this annotation

GitHub Actions / check

'_kind' is assigned a value but never used
const result = articleJsonLd(inputWithoutKind);
expect(result["@type"]).toBe("BlogPosting");
});
});
5 changes: 2 additions & 3 deletions src/app/article/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import ResponsiveImage from "@/components/ui/ResponsiveImage";
import Link from "next/link";
import { getAllPosts, getPostBySlug, getCategorySlug, getPostsByCategory, extractHeadings, isSymfonyAuditCategory, isTechCategory, readingTime } from "@/lib/blog";
import { getAllPosts, getPostBySlug, getCategorySlug, getPostsByCategory, extractHeadings, isSymfonyAuditCategory, readingTime } from "@/lib/blog";
import Container from "@/components/ui/Container";
import Button from "@/components/ui/Button";
import MarkdownContent from "@/components/ui/MarkdownContent";
Expand Down Expand Up @@ -95,7 +95,6 @@ export default async function ArticlePage({ params }: ArticlePageProps) {

const shouldShowStickyCta = post.wordCount > STICKY_CTA_MIN_WORDS;
const stickyCtaConfig = getArticleCtaConfig(post.category, slug);
const isTech = isTechCategory(post.category);

const headings = extractHeadings(post.content);

Expand All @@ -107,7 +106,7 @@ export default async function ArticlePage({ params }: ArticlePageProps) {

const jsonLd = articleJsonLd({
url,
isTech,
kind: post.kind,
title: post.title,
excerpt: post.excerpt,
author: getAuthorSchema(post.author),
Expand Down
Loading
Loading