AI Implementation feature(869): Admin Area Blog and Blog Page (#58)
This commit was merged in pull request #58.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user