AI Implementation feature(869): Admin Area Blog and Blog Page (#58)

This commit was merged in pull request #58.
This commit is contained in:
2026-07-23 10:17:55 +00:00
parent d468cca766
commit 294873240b
32 changed files with 1798 additions and 117 deletions
+114
View File
@@ -0,0 +1,114 @@
import '@angular/compiler';
import { FormBuilder } from '@angular/forms';
import {
PostFormGroup,
buildPostBody,
statusBadgeClass,
statusLabel,
syncPostForm,
validatePostForm,
} from '../../frontend/src/app/features/admin/blog/blog-form.pure';
import { AdminBlogPost } from '../../frontend/src/app/core/services/blog.service';
function buildForm(): PostFormGroup {
const fb = new FormBuilder();
return fb.nonNullable.group({
title: fb.nonNullable.control(''),
bodyMd: fb.nonNullable.control(''),
});
}
function makePost(overrides: Partial<AdminBlogPost> = {}): AdminBlogPost {
return {
id: 'p-1',
title: 'Existing Title',
bodyMd: 'existing body',
status: 'draft',
createdAt: '2026-07-21T10:00:00.000Z',
updatedAt: '2026-07-22T10:00:00.000Z',
publishedAt: null,
...overrides,
};
}
describe('validatePostForm', () => {
it('rejects empty title', () => {
const r = validatePostForm({ title: '', bodyMd: 'x' });
expect(r.ok).toBe(false);
expect(r.errors.title).toBeDefined();
});
it('rejects whitespace-only title', () => {
const r = validatePostForm({ title: ' \t ', bodyMd: 'x' });
expect(r.ok).toBe(false);
expect(r.errors.title).toBeDefined();
});
it('rejects title longer than 200 characters', () => {
const r = validatePostForm({ title: 'a'.repeat(201), bodyMd: '' });
expect(r.ok).toBe(false);
expect(r.errors.title).toBeDefined();
});
it('rejects body longer than 200000 characters', () => {
const r = validatePostForm({ title: 'ok', bodyMd: 'x'.repeat(200_001) });
expect(r.ok).toBe(false);
expect(r.errors.bodyMd).toBeDefined();
});
it('accepts a valid title and empty body', () => {
const r = validatePostForm({ title: ' Real Title ', bodyMd: '' });
expect(r.ok).toBe(true);
expect(r.errors.title).toBeUndefined();
expect(r.errors.bodyMd).toBeUndefined();
});
});
describe('syncPostForm', () => {
it('prefills the form for edit mode with a post', () => {
const form = buildForm();
syncPostForm(form, 'edit', makePost());
expect(form.controls.title.value).toBe('Existing Title');
expect(form.controls.bodyMd.value).toBe('existing body');
expect(form.controls.title.pristine).toBe(true);
expect(form.controls.title.touched).toBe(false);
});
it('clears the form for create mode with null post', () => {
const form = buildForm();
syncPostForm(form, 'edit', makePost());
expect(form.controls.title.value).toBe('Existing Title');
syncPostForm(form, 'create', null);
expect(form.controls.title.value).toBe('');
expect(form.controls.bodyMd.value).toBe('');
});
it('re-populates when invoked a second time with a different post', () => {
const form = buildForm();
syncPostForm(form, 'edit', makePost({ title: 'Alpha' }));
expect(form.controls.title.value).toBe('Alpha');
syncPostForm(form, 'edit', makePost({ title: 'Bravo', bodyMd: 'b body' }));
expect(form.controls.title.value).toBe('Bravo');
expect(form.controls.bodyMd.value).toBe('b body');
});
});
describe('buildPostBody', () => {
it('trims the title and passes body through', () => {
expect(buildPostBody({ title: ' hello ', bodyMd: 'md' })).toEqual({
title: 'hello',
bodyMd: 'md',
});
});
});
describe('statusBadgeClass / statusLabel', () => {
it('maps draft → badge-draft and "Draft"', () => {
expect(statusBadgeClass('draft')).toBe('badge-draft');
expect(statusLabel('draft')).toBe('Draft');
});
it('maps published → badge-published and "Published"', () => {
expect(statusBadgeClass('published')).toBe('badge-published');
expect(statusLabel('published')).toBe('Published');
});
});
+41
View File
@@ -0,0 +1,41 @@
import { deriveBlogListState } from '../../frontend/src/app/features/blog/blog.pure';
import { renderMarkdownToHtml } from '../../frontend/src/app/core/services/markdown.pure';
describe('deriveBlogListState', () => {
it('returns loading when loading is true (regardless of error/posts)', () => {
expect(deriveBlogListState({ loading: true, error: null, count: 0 }).kind).toBe('loading');
expect(deriveBlogListState({ loading: true, error: 'x', count: 3 }).kind).toBe('loading');
});
it('returns error when not loading and error message set', () => {
const s = deriveBlogListState({ loading: false, error: 'boom', count: 0 });
expect(s.kind).toBe('error');
expect(s.message).toBe('boom');
});
it('returns empty when not loading and no posts', () => {
expect(deriveBlogListState({ loading: false, error: null, count: 0 }).kind).toBe('empty');
});
it('returns list when posts exist', () => {
expect(deriveBlogListState({ loading: false, error: null, count: 3 }).kind).toBe('list');
});
});
describe('shared Markdown presentation', () => {
it('produces the same HTML for the same Markdown body (sanitized, no script)', () => {
const md = '# Title\n\nSome **bold** text and <script>alert(1)</script>';
const a = renderMarkdownToHtml(md);
const b = renderMarkdownToHtml(md);
expect(a).toBe(b);
expect(a).toContain('<h1>Title</h1>');
expect(a).toContain('<strong>bold</strong>');
expect(a).not.toContain('<script>');
});
it('strips dangerous javascript: hrefs in both contexts', () => {
const md = '[click](javascript:alert(1))';
const a = renderMarkdownToHtml(md);
expect(a).not.toMatch(/href="javascript:/i);
});
});