feat: Admin Area Blog and Blog Page
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# Fix Plan: TS2459 PostFormSubmitPayload not exported
|
||||
|
||||
## 1. Root Cause
|
||||
|
||||
`frontend/src/app/features/admin/blog/blog-form.pure.ts` declares `PostFormSubmitPayload` and `PostFormMode` as exports, but `frontend/src/app/features/admin/blog/blog-form-modal.component.ts` only **imports** them — it does not re-export them.
|
||||
|
||||
`frontend/src/app/features/admin/blog/blog.component.ts:10` imports `PostFormSubmitPayload` from `./blog-form-modal.component`, which has no such export. Under `ng build --configuration production` (which uses `compilationMode: "full"` Angular strict template/type-check), this is a hard `TS2459` error and the bundle fails.
|
||||
|
||||
The existing codebase convention is the opposite: `category-form-modal.component.ts` declares `CategoryFormSubmit` / `CategoryFormMode` **inside** the modal file and consumers (`categories.component.ts`) import them from the modal component directly. My initial layout diverged from that pattern by colocating the modal-output types in the `.pure.ts` module.
|
||||
|
||||
## 2. Fix (chosen approach: match the existing `CategoryFormSubmit` convention)
|
||||
|
||||
Move the two UI-emit types out of `blog-form.pure.ts` and into `blog-form-modal.component.ts`, then update the two importers.
|
||||
|
||||
### 2.1 `frontend/src/app/features/admin/blog/blog-form-modal.component.ts` (modify)
|
||||
|
||||
Add at the top (right after the existing imports from `./blog-form.pure`):
|
||||
|
||||
```ts
|
||||
export type PostFormMode = 'create' | 'edit';
|
||||
|
||||
export interface PostFormSubmitPayload {
|
||||
mode: PostFormMode;
|
||||
title: string;
|
||||
bodyMd: string;
|
||||
action: 'draft' | 'publish';
|
||||
}
|
||||
```
|
||||
|
||||
Remove these two names from the import list at line 19-20:
|
||||
|
||||
```ts
|
||||
// before
|
||||
import {
|
||||
PostFormGroup,
|
||||
PostFormMode, // ← remove
|
||||
PostFormSubmitPayload, // ← remove
|
||||
buildPostBody,
|
||||
syncPostForm,
|
||||
validatePostForm,
|
||||
} from './blog-form.pure';
|
||||
|
||||
// after
|
||||
import {
|
||||
PostFormGroup,
|
||||
buildPostBody,
|
||||
syncPostForm,
|
||||
validatePostForm,
|
||||
} from './blog-form.pure';
|
||||
```
|
||||
|
||||
`PostFormGroup` stays imported from the `.pure.ts` module (it is the pure form-shape type, used internally by the modal).
|
||||
|
||||
### 2.2 `frontend/src/app/features/admin/blog/blog-form.pure.ts` (modify)
|
||||
|
||||
Remove the two declarations (lines 4 and 11-16) so the pure module no longer leaks UI-emit types:
|
||||
|
||||
- Delete `export type PostFormMode = 'create' | 'edit';`
|
||||
- Delete the entire `export interface PostFormSubmitPayload { ... }` block.
|
||||
|
||||
Keep the rest of the file unchanged:
|
||||
- `PostFormGroup`
|
||||
- `MAX_TITLE_LENGTH` / `MAX_BODY_LENGTH`
|
||||
- `validatePostForm`, `syncPostForm`, `buildPostBody`, `statusBadgeClass`, `statusLabel`
|
||||
|
||||
### 2.3 No other edits required
|
||||
|
||||
- `frontend/src/app/features/admin/blog/blog.component.ts` already imports `PostFormSubmitPayload` from `./blog-form-modal.component` — after step 2.1 that import resolves correctly. No change.
|
||||
- `tests/frontend/blog-admin-form.spec.ts` does **not** import `PostFormMode` or `PostFormSubmitPayload` (verified — only `PostFormGroup`, `buildPostBody`, `statusBadgeClass`, `statusLabel`, `syncPostForm`, `validatePostForm`). No change.
|
||||
- `tests/frontend/blog-page.spec.ts` is unaffected.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
After the fix:
|
||||
|
||||
1. `cd /repo && npx ng build --configuration production` must succeed (the original failing command).
|
||||
2. `cd /repo && npm test` must continue to pass (89 suites, 678 tests).
|
||||
3. The `BlogFormModalComponent.submit` output still emits the same `PostFormSubmitPayload` shape — `blog.component.ts:onFormSubmit` is unchanged.
|
||||
|
||||
## 4. Why this fix over the alternative
|
||||
|
||||
Alternative considered: change the import in `blog.component.ts` from `'./blog-form-modal.component'` to `'./blog-form.pure'`. This is a one-line change but it diverges from the established `CategoryFormSubmit` pattern (modal-emit types live with the modal), and it forces every future consumer of the form payload to import from the `.pure.ts` module instead of the modal. The chosen fix aligns this feature with the rest of the codebase and keeps the modal's public API self-contained.
|
||||
@@ -1,70 +0,0 @@
|
||||
# Implementation Plan: Fix Job 913 Build Error (missing `auth` injection in AdminUsersComponent)
|
||||
|
||||
## 1. Root cause
|
||||
The `onToggleRole` handler added in the previous turn calls
|
||||
`this.auth.currentUser()`, `this.auth.applyPeerRoleChange(...)`, and
|
||||
`this.auth.publishRoleChange(...)`
|
||||
(`frontend/src/app/features/admin/admin-users.component.ts:244-247`), but
|
||||
`AdminUsersComponent` never declared an `auth` field. The component only injects
|
||||
`AdminService` (`admin`) and `NotificationService` (`notify`). TypeScript build
|
||||
(`ng build --configuration production`) therefore fails with `TS2339: Property
|
||||
'auth' does not exist on type 'AdminUsersComponent'` at lines 244, 246, 247.
|
||||
|
||||
The Jest tests passed because they exercise pure modules and
|
||||
`admin-players.pure`, not the component class — so the missing field was not
|
||||
caught at test time.
|
||||
|
||||
## 2. Required changes (single file)
|
||||
|
||||
**File:** `frontend/src/app/features/admin/admin-users.component.ts`
|
||||
|
||||
1. **Add the import** alongside the existing core-service imports (lines 10-11
|
||||
currently import `AdminService` and `NotificationService`):
|
||||
|
||||
```ts
|
||||
import { AuthService, AdminUser } from '...';
|
||||
```
|
||||
|
||||
The `AdminUser` type can be left on the existing `AdminService` import line. We
|
||||
must add `AuthService` and import only the value (not the type) — `AuthService`
|
||||
is the singleton provided in `'root'` by `core/services/auth.service.ts`.
|
||||
|
||||
The accurate edit is:
|
||||
|
||||
- Replace the existing `import { AdminService, AdminUser } from '...admin.service';`
|
||||
with two separate imports, or add the new import:
|
||||
|
||||
```ts
|
||||
import { AdminService, AdminUser } from '../../core/services/admin.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
```
|
||||
|
||||
2. **Inject the service** alongside the two existing injections (lines ~170-171):
|
||||
|
||||
```ts
|
||||
private readonly admin = inject(AdminService);
|
||||
private readonly notify = inject(NotificationService);
|
||||
private readonly auth = inject(AuthService);
|
||||
```
|
||||
|
||||
That is the only change required — the existing `onToggleRole` body already
|
||||
references `this.auth.currentUser`, `this.auth.applyPeerRoleChange`, and
|
||||
`this.auth.publishRoleChange`, all of which exist on `AuthService`.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- `npm --workspace frontend run build` should pass (no TypeScript errors).
|
||||
- `npm test` should still pass — the existing pure tests
|
||||
(`tests/frontend/admin-players.pure.spec.ts`,
|
||||
`tests/frontend/auth-session-events.spec.ts`,
|
||||
`tests/backend/admin-players.spec.ts`) are unaffected by adding the
|
||||
injection.
|
||||
- No public API, route, store, or service signature changes are required.
|
||||
|
||||
## 4. Out of scope
|
||||
|
||||
- No backend changes.
|
||||
- No test additions (the build error is a pure TypeScript declaration
|
||||
issue; tests already cover the behaviour).
|
||||
- No re-architecture of cross-tab role-change handling — the previous turn's
|
||||
implementation is correct, only the missing field declaration is needed.
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import {
|
||||
AdminBlogPost,
|
||||
CreatePostPayload,
|
||||
CreatePostSchema,
|
||||
PostIdParamSchema,
|
||||
UpdatePostPayload,
|
||||
UpdatePostSchema,
|
||||
} from './dto/blog.dto';
|
||||
import { AdminBlogService } from './admin-blog.service';
|
||||
|
||||
@ApiTags('admin')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api/v1/admin/blog/posts')
|
||||
@UseGuards(AdminGuard)
|
||||
@Roles('admin')
|
||||
export class AdminBlogController {
|
||||
constructor(private readonly admin: AdminBlogService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all blog posts (admin only, drafts included)' })
|
||||
async list(): Promise<AdminBlogPost[]> {
|
||||
return this.admin.listAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a single blog post by id (admin only)' })
|
||||
async getOne(
|
||||
@Param(new ZodValidationPipe(PostIdParamSchema)) params: { id: string },
|
||||
): Promise<AdminBlogPost> {
|
||||
return this.admin.getById(params.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new blog post (admin only)' })
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(CreatePostSchema)) body: CreatePostPayload,
|
||||
): Promise<AdminBlogPost> {
|
||||
return this.admin.create(body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update a blog post (admin only)' })
|
||||
async update(
|
||||
@Param(new ZodValidationPipe(PostIdParamSchema)) params: { id: string },
|
||||
@Body(new ZodValidationPipe(UpdatePostSchema)) body: UpdatePostPayload,
|
||||
): Promise<AdminBlogPost> {
|
||||
return this.admin.update(params.id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Delete a blog post (admin only)' })
|
||||
async remove(
|
||||
@Param(new ZodValidationPipe(PostIdParamSchema)) params: { id: string },
|
||||
): Promise<void> {
|
||||
await this.admin.remove(params.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
|
||||
import { ApiError } from '../../common/errors/api-error';
|
||||
import { AdminBlogPost, CreatePostPayload, UpdatePostPayload } from './dto/blog.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminBlogService {
|
||||
constructor(
|
||||
@InjectRepository(BlogPostEntity)
|
||||
private readonly posts: Repository<BlogPostEntity>,
|
||||
) {}
|
||||
|
||||
async listAll(): Promise<AdminBlogPost[]> {
|
||||
const rows = await this.posts.find({ order: { updatedAt: 'DESC' } });
|
||||
return rows.map((r) => this.toView(r));
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AdminBlogPost> {
|
||||
const row = await this.posts.findOne({ where: { id } });
|
||||
if (!row) throw ApiError.notFound('Post not found');
|
||||
return this.toView(row);
|
||||
}
|
||||
|
||||
async create(payload: CreatePostPayload): Promise<AdminBlogPost> {
|
||||
const status = payload.status ?? 'draft';
|
||||
const row = this.posts.create({
|
||||
id: uuid(),
|
||||
title: payload.title.trim(),
|
||||
bodyMd: payload.bodyMd ?? '',
|
||||
status,
|
||||
publishedAt: status === 'published' ? new Date().toISOString() : null,
|
||||
});
|
||||
await this.posts.save(row);
|
||||
return this.toView(row);
|
||||
}
|
||||
|
||||
async update(id: string, payload: UpdatePostPayload): Promise<AdminBlogPost> {
|
||||
const row = await this.posts.findOne({ where: { id } });
|
||||
if (!row) throw ApiError.notFound('Post not found');
|
||||
|
||||
if (payload.title !== undefined) row.title = payload.title.trim();
|
||||
if (payload.bodyMd !== undefined) row.bodyMd = payload.bodyMd;
|
||||
|
||||
if (payload.status !== undefined) {
|
||||
if (payload.status === 'published') {
|
||||
row.status = 'published';
|
||||
// Only stamp publishedAt if this post has never been published before;
|
||||
// preserve the original publishedAt for re-publications.
|
||||
if (!row.publishedAt) {
|
||||
row.publishedAt = new Date().toISOString();
|
||||
}
|
||||
} else if (payload.status === 'draft') {
|
||||
row.status = 'draft';
|
||||
// preserve publishedAt so a later re-publish keeps the original publication timestamp.
|
||||
}
|
||||
}
|
||||
|
||||
await this.posts.save(row);
|
||||
return this.toView(row);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const row = await this.posts.findOne({ where: { id } });
|
||||
if (!row) throw ApiError.notFound('Post not found');
|
||||
await this.posts.delete({ id });
|
||||
}
|
||||
|
||||
private toView(r: BlogPostEntity): AdminBlogPost {
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
bodyMd: r.bodyMd ?? '',
|
||||
status: r.status,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
publishedAt: r.publishedAt ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
|
||||
import { BlogController } from './blog.controller';
|
||||
import { BlogService } from './blog.service';
|
||||
import { AdminBlogController } from './admin-blog.controller';
|
||||
import { AdminBlogService } from './admin-blog.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BlogPostEntity])],
|
||||
controllers: [BlogController],
|
||||
providers: [BlogService],
|
||||
controllers: [BlogController, AdminBlogController],
|
||||
providers: [BlogService, AdminBlogService],
|
||||
})
|
||||
export class BlogModule {}
|
||||
export class BlogModule {}
|
||||
|
||||
@@ -11,4 +11,40 @@ export type PublicBlogPost = z.infer<typeof PublicBlogPostSchema>;
|
||||
export const PublicBlogListSchema = z.object({
|
||||
posts: z.array(PublicBlogPostSchema),
|
||||
});
|
||||
export type PublicBlogList = z.infer<typeof PublicBlogListSchema>;
|
||||
export type PublicBlogList = z.infer<typeof PublicBlogListSchema>;
|
||||
|
||||
export const AdminBlogPostSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
bodyMd: z.string(),
|
||||
status: z.enum(['draft', 'published']),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
publishedAt: z.string().nullable(),
|
||||
});
|
||||
export type AdminBlogPost = z.infer<typeof AdminBlogPostSchema>;
|
||||
|
||||
export const CreatePostSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(1, 'Title is required')
|
||||
.max(200, 'Title must be at most 200 characters')
|
||||
.refine((t) => t.trim().length > 0, { message: 'Title is required' }),
|
||||
bodyMd: z.string().max(200_000, 'Body must be at most 200000 characters').default(''),
|
||||
status: z.enum(['draft', 'published']).optional(),
|
||||
});
|
||||
export type CreatePostPayload = z.infer<typeof CreatePostSchema>;
|
||||
|
||||
export const UpdatePostSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(1, 'Title is required')
|
||||
.max(200, 'Title must be at most 200 characters')
|
||||
.refine((t) => t.trim().length > 0, { message: 'Title is required' })
|
||||
.optional(),
|
||||
bodyMd: z.string().max(200_000, 'Body must be at most 200000 characters').optional(),
|
||||
status: z.enum(['draft', 'published']).optional(),
|
||||
});
|
||||
export type UpdatePostPayload = z.infer<typeof UpdatePostSchema>;
|
||||
|
||||
export const PostIdParamSchema = z.object({ id: z.string().min(1).max(64) });
|
||||
|
||||
@@ -64,6 +64,11 @@ export const APP_ROUTES: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/admin/admin-users.component').then((m) => m.AdminUsersComponent),
|
||||
},
|
||||
{
|
||||
path: 'blog',
|
||||
loadComponent: () =>
|
||||
import('./features/admin/blog/blog.component').then((m) => m.AdminBlogComponent),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export type BlogStatus = 'draft' | 'published';
|
||||
|
||||
export interface AdminBlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
bodyMd: string;
|
||||
status: BlogStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreatePostBody {
|
||||
title: string;
|
||||
bodyMd: string;
|
||||
status?: BlogStatus;
|
||||
}
|
||||
|
||||
export interface UpdatePostBody {
|
||||
title?: string;
|
||||
bodyMd?: string;
|
||||
status?: BlogStatus;
|
||||
}
|
||||
|
||||
export interface PublicBlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
publishedAt: string;
|
||||
bodyMd: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BlogApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
async listAllPosts(): Promise<AdminBlogPost[]> {
|
||||
return firstValueFrom(
|
||||
this.http.get<AdminBlogPost[]>('/api/v1/admin/blog/posts', { withCredentials: true }),
|
||||
);
|
||||
}
|
||||
|
||||
async createPost(body: CreatePostBody): Promise<AdminBlogPost> {
|
||||
return firstValueFrom(
|
||||
this.http.post<AdminBlogPost>('/api/v1/admin/blog/posts', body, { withCredentials: true }),
|
||||
);
|
||||
}
|
||||
|
||||
async updatePost(id: string, body: UpdatePostBody): Promise<AdminBlogPost> {
|
||||
return firstValueFrom(
|
||||
this.http.put<AdminBlogPost>(`/api/v1/admin/blog/posts/${encodeURIComponent(id)}`, body, {
|
||||
withCredentials: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deletePost(id: string): Promise<void> {
|
||||
await firstValueFrom(
|
||||
this.http.delete<void>(`/api/v1/admin/blog/posts/${encodeURIComponent(id)}`, {
|
||||
withCredentials: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listPublished(): Promise<PublicBlogPost[]> {
|
||||
const res = await firstValueFrom(
|
||||
this.http.get<{ posts: PublicBlogPost[] }>('/api/v1/blog/posts', { withCredentials: true }),
|
||||
);
|
||||
return res.posts ?? [];
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const ENTRIES: AdminNavEntry[] = [
|
||||
{ id: 'general', label: 'General', path: '/admin/general', enabled: true },
|
||||
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true },
|
||||
{ id: 'players', label: 'Players', path: '/admin/players', enabled: true },
|
||||
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: false },
|
||||
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: true },
|
||||
{ id: 'system', label: 'System', path: '/admin/system', enabled: false },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AdminBlogPost } from '../../../core/services/blog.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blog-delete-modal',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 360px; max-width: 90vw; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.error { color: var(--color-danger, #f00); margin-top: 8px; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-backdrop" data-testid="post-delete-backdrop" (click)="onCancel()">
|
||||
<div class="modal" (click)="$event.stopPropagation()" data-testid="post-delete-modal">
|
||||
<h3>Delete post</h3>
|
||||
@if (post(); as p) {
|
||||
<p data-testid="post-delete-name">
|
||||
Delete <b>{{ p.title }}</b>? This cannot be undone.
|
||||
</p>
|
||||
}
|
||||
@if (errorMessage()) {
|
||||
<p class="error" data-testid="post-delete-error">{{ errorMessage() }}</p>
|
||||
}
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" data-testid="post-delete-cancel">Cancel</button>
|
||||
<button type="button" [disabled]="deleting()" (click)="onConfirm()" data-testid="post-delete-ok">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BlogDeleteModalComponent {
|
||||
readonly open = input(false);
|
||||
readonly post = input<AdminBlogPost | null>(null);
|
||||
readonly errorMessage = input<string | null>(null);
|
||||
readonly deleting = input(false);
|
||||
|
||||
readonly cancel = output<void>();
|
||||
readonly confirm = output<void>();
|
||||
|
||||
onCancel(): void {
|
||||
this.cancel.emit();
|
||||
}
|
||||
|
||||
onConfirm(): void {
|
||||
this.confirm.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MarkdownService } from '../../../core/services/markdown.service';
|
||||
import { AdminBlogPost } from '../../../core/services/blog.service';
|
||||
import {
|
||||
PostFormGroup,
|
||||
buildPostBody,
|
||||
syncPostForm,
|
||||
validatePostForm,
|
||||
} from './blog-form.pure';
|
||||
|
||||
export type PostFormMode = 'create' | 'edit';
|
||||
|
||||
export interface PostFormSubmitPayload {
|
||||
mode: PostFormMode;
|
||||
title: string;
|
||||
bodyMd: string;
|
||||
action: 'draft' | 'publish';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-blog-form-modal',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
styles: [`
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 480px; max-width: 90vw; max-height: 90vh; overflow: auto; }
|
||||
.row { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.layout { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.preview { border: 1px solid var(--color-secondary, #ccc); padding: 8px; border-radius: var(--radius-sm, 4px); background: var(--color-surface, #fafafa); min-height: 200px; max-height: 320px; overflow: auto; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.field-error { color: var(--color-danger, #f00); font-size: 12px; }
|
||||
.editing-meta { font-size: 12px; opacity: 0.7; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-backdrop" data-testid="post-form-backdrop" (click)="onCancel()">
|
||||
<form
|
||||
class="modal"
|
||||
[formGroup]="form"
|
||||
(click)="$event.stopPropagation()"
|
||||
(submit)="$event.preventDefault()"
|
||||
data-testid="post-form-modal"
|
||||
>
|
||||
<h3>{{ mode() === 'create' ? 'New post' : 'Edit post' }}</h3>
|
||||
|
||||
@if (post(); as p) {
|
||||
@if (mode() === 'edit') {
|
||||
<p class="editing-meta">
|
||||
Created: {{ p.createdAt | date: 'medium' }} ·
|
||||
Updated: {{ p.updatedAt | date: 'medium' }}
|
||||
@if (p.publishedAt) {
|
||||
· Published: {{ p.publishedAt | date: 'medium' }}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<label for="pf-title">Title (required)</label>
|
||||
<input
|
||||
id="pf-title"
|
||||
type="text"
|
||||
formControlName="title"
|
||||
data-testid="pf-title"
|
||||
[attr.aria-invalid]="titleError() ? 'true' : null"
|
||||
[attr.aria-describedby]="titleError() ? 'pf-title-error' : null"
|
||||
/>
|
||||
@if (titleError()) {
|
||||
<span class="field-error" id="pf-title-error" data-testid="pf-title-error">{{ titleError() }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<div class="row">
|
||||
<label for="pf-body">Markdown body</label>
|
||||
<textarea
|
||||
id="pf-body"
|
||||
rows="14"
|
||||
formControlName="bodyMd"
|
||||
data-testid="pf-body"
|
||||
(input)="onBodyInput()"
|
||||
></textarea>
|
||||
@if (bodyError()) {
|
||||
<span class="field-error" data-testid="pf-body-error">{{ bodyError() }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Live preview</label>
|
||||
<div class="preview" data-testid="pf-preview" [innerHTML]="previewHtml()"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="field-error" data-testid="pf-error" style="margin-top: 8px;">{{ errorMessage() }}</p>
|
||||
}
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" data-testid="pf-cancel">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="!canSaveDraft()"
|
||||
(click)="onSubmit('draft')"
|
||||
data-testid="pf-save-draft"
|
||||
>Save draft</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="!canPublish()"
|
||||
(click)="onSubmit('publish')"
|
||||
data-testid="pf-publish"
|
||||
>Publish</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BlogFormModalComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly markdown = inject(MarkdownService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
|
||||
readonly open = input(false);
|
||||
readonly mode = input<PostFormMode>('create');
|
||||
readonly post = input<AdminBlogPost | null>(null);
|
||||
readonly errorMessage = input<string | null>(null);
|
||||
readonly saving = input(false);
|
||||
|
||||
readonly cancel = output<void>();
|
||||
readonly submit = output<PostFormSubmitPayload>();
|
||||
|
||||
readonly previewHtml = signal<string>('');
|
||||
readonly titleTouched = signal(false);
|
||||
readonly bodyTouched = signal(false);
|
||||
|
||||
readonly form: PostFormGroup = this.fb.nonNullable.group({
|
||||
title: this.fb.nonNullable.control(''),
|
||||
bodyMd: this.fb.nonNullable.control(''),
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
syncPostForm(this.form, this.mode(), this.post());
|
||||
this.titleTouched.set(false);
|
||||
this.bodyTouched.set(false);
|
||||
this.previewHtml.set(this.markdown.render(this.form.controls.bodyMd.value ?? ''));
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
{ allowSignalWrites: true },
|
||||
);
|
||||
|
||||
this.form.controls.title.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => {
|
||||
this.titleTouched.set(true);
|
||||
});
|
||||
this.form.controls.bodyMd.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((v) => {
|
||||
this.bodyTouched.set(true);
|
||||
this.previewHtml.set(this.markdown.render(v ?? ''));
|
||||
});
|
||||
}
|
||||
|
||||
onBodyInput(): void {
|
||||
// valueChanges already triggers the preview; this is here as an explicit
|
||||
// hook in case the template wiring changes.
|
||||
}
|
||||
|
||||
titleError(): string | null {
|
||||
const v = this.form.controls.title.value ?? '';
|
||||
if (!this.titleTouched() && v.length === 0) return null;
|
||||
const result = validatePostForm({ title: v, bodyMd: this.form.controls.bodyMd.value ?? '' });
|
||||
return result.errors.title ?? null;
|
||||
}
|
||||
|
||||
bodyError(): string | null {
|
||||
const v = this.form.controls.bodyMd.value ?? '';
|
||||
if (!this.bodyTouched() && v.length === 0) return null;
|
||||
const result = validatePostForm({ title: this.form.controls.title.value ?? '', bodyMd: v });
|
||||
return result.errors.bodyMd ?? null;
|
||||
}
|
||||
|
||||
canSaveDraft(): boolean {
|
||||
if (this.saving()) return false;
|
||||
const v = this.form.getRawValue();
|
||||
const result = validatePostForm({ title: v.title ?? '', bodyMd: v.bodyMd ?? '' });
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
canPublish(): boolean {
|
||||
return this.canSaveDraft();
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.cancel.emit();
|
||||
}
|
||||
|
||||
onSubmit(action: 'draft' | 'publish'): void {
|
||||
this.titleTouched.set(true);
|
||||
this.bodyTouched.set(true);
|
||||
const raw = this.form.getRawValue();
|
||||
const result = validatePostForm({ title: raw.title ?? '', bodyMd: raw.bodyMd ?? '' });
|
||||
if (!result.ok) return;
|
||||
const cleaned = buildPostBody({ title: raw.title ?? '', bodyMd: raw.bodyMd ?? '' });
|
||||
this.submit.emit({ mode: this.mode(), ...cleaned, action });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { AdminBlogPost, BlogStatus } from '../../../core/services/blog.service';
|
||||
|
||||
export type PostFormGroup = FormGroup<{
|
||||
title: FormControl<string>;
|
||||
bodyMd: FormControl<string>;
|
||||
}>;
|
||||
|
||||
export const MAX_TITLE_LENGTH = 200;
|
||||
export const MAX_BODY_LENGTH = 200_000;
|
||||
|
||||
export function validatePostForm(value: { title: string; bodyMd: string }): {
|
||||
ok: boolean;
|
||||
errors: { title?: string; bodyMd?: string };
|
||||
} {
|
||||
const errors: { title?: string; bodyMd?: string } = {};
|
||||
const title = (value.title ?? '').trim();
|
||||
if (title.length === 0) errors.title = 'Title is required';
|
||||
else if (title.length > MAX_TITLE_LENGTH) errors.title = `Title must be at most ${MAX_TITLE_LENGTH} characters`;
|
||||
|
||||
const body = value.bodyMd ?? '';
|
||||
if (body.length > MAX_BODY_LENGTH) errors.bodyMd = `Body must be at most ${MAX_BODY_LENGTH} characters`;
|
||||
|
||||
return { ok: Object.keys(errors).length === 0, errors };
|
||||
}
|
||||
|
||||
export function syncPostForm(
|
||||
form: PostFormGroup,
|
||||
mode: 'create' | 'edit',
|
||||
post: AdminBlogPost | null,
|
||||
): void {
|
||||
if (mode === 'edit' && post) {
|
||||
form.controls.title.setValue(post.title ?? '', { emitEvent: false });
|
||||
form.controls.bodyMd.setValue(post.bodyMd ?? '', { emitEvent: false });
|
||||
} else {
|
||||
form.controls.title.setValue('', { emitEvent: false });
|
||||
form.controls.bodyMd.setValue('', { emitEvent: false });
|
||||
}
|
||||
form.controls.title.markAsUntouched();
|
||||
form.controls.title.markAsPristine();
|
||||
form.controls.bodyMd.markAsUntouched();
|
||||
form.controls.bodyMd.markAsPristine();
|
||||
}
|
||||
|
||||
export function buildPostBody(formValue: { title: string; bodyMd: string }): {
|
||||
title: string;
|
||||
bodyMd: string;
|
||||
} {
|
||||
return { title: formValue.title.trim(), bodyMd: formValue.bodyMd ?? '' };
|
||||
}
|
||||
|
||||
export function statusBadgeClass(status: BlogStatus): string {
|
||||
return status === 'published' ? 'badge-published' : 'badge-draft';
|
||||
}
|
||||
|
||||
export function statusLabel(status: BlogStatus): string {
|
||||
return status === 'published' ? 'Published' : 'Draft';
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { BlogApiService, AdminBlogPost } from '../../../core/services/blog.service';
|
||||
import { BlogFormModalComponent, PostFormSubmitPayload } from './blog-form-modal.component';
|
||||
import { BlogDeleteModalComponent } from './blog-delete-modal.component';
|
||||
import { statusBadgeClass, statusLabel } from './blog-form.pure';
|
||||
|
||||
type ModalMode = 'create' | 'edit' | null;
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-blog',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, BlogFormModalComponent, BlogDeleteModalComponent],
|
||||
styles: [`
|
||||
.admin-blog { display: flex; flex-direction: column; gap: 12px; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; }
|
||||
.toolbar h2 { margin: 0; }
|
||||
.status-banner { padding: 8px; border-radius: var(--radius-sm, 4px); }
|
||||
.status-banner.success { background: #ecfdf5; color: #065f46; }
|
||||
.status-banner.error { background: #fee2e2; color: #991b1b; }
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th, .table td { padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc); text-align: left; vertical-align: top; }
|
||||
.badge { padding: 2px 6px; border-radius: 4px; font-size: 12px; }
|
||||
.badge-draft { background: var(--color-secondary, #ddd); color: #444; }
|
||||
.badge-published { background: #d1fae5; color: #065f46; }
|
||||
.row-actions button { margin-left: 4px; }
|
||||
.empty { text-align: center; padding: 16px; opacity: 0.7; }
|
||||
button[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||
`],
|
||||
template: `
|
||||
<section class="admin-blog" data-testid="admin-blog">
|
||||
<div class="toolbar">
|
||||
<h2>Blog</h2>
|
||||
<button type="button" (click)="openCreate()" [disabled]="actionInFlight()" data-testid="blog-add">
|
||||
+ New post
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (statusMessage()) {
|
||||
<p class="status-banner success" role="status" aria-live="polite" data-testid="blog-status">
|
||||
{{ statusMessage() }}
|
||||
</p>
|
||||
}
|
||||
@if (loadError()) {
|
||||
<p class="status-banner error" role="alert" aria-live="assertive" data-testid="blog-error">
|
||||
{{ loadError() }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<p data-testid="blog-loading">Loading posts…</p>
|
||||
} @else if (posts().length === 0) {
|
||||
<p class="empty" data-testid="blog-empty">No posts yet.</p>
|
||||
} @else {
|
||||
<table class="table" data-testid="blog-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Published</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of posts(); track p.id) {
|
||||
<tr [attr.data-testid]="'blog-row-' + p.id">
|
||||
<td>{{ p.title }}</td>
|
||||
<td>
|
||||
<span class="badge" [class]="statusBadgeClass(p.status)" [attr.data-testid]="'blog-status-' + p.id">
|
||||
{{ statusLabel(p.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ p.createdAt | date: 'medium' }}</td>
|
||||
<td>{{ p.updatedAt | date: 'medium' }}</td>
|
||||
<td>
|
||||
@if (p.publishedAt) {
|
||||
{{ p.publishedAt | date: 'medium' }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<button type="button" (click)="openEdit(p)" [disabled]="actionInFlight()" [attr.data-testid]="'blog-edit-' + p.id">✎</button>
|
||||
<button type="button" (click)="openDelete(p)" [disabled]="actionInFlight()" [attr.data-testid]="'blog-delete-' + p.id">🗑</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<app-blog-form-modal
|
||||
[open]="modalMode() !== null"
|
||||
[mode]="modalMode() === 'edit' ? 'edit' : 'create'"
|
||||
[post]="editing()"
|
||||
[errorMessage]="formError()"
|
||||
[saving]="saving()"
|
||||
(cancel)="closeModal()"
|
||||
(submit)="onFormSubmit($event)"
|
||||
/>
|
||||
|
||||
<app-blog-delete-modal
|
||||
[open]="deleteOpen()"
|
||||
[post]="deleting()"
|
||||
[errorMessage]="deleteError()"
|
||||
[deleting]="deleting() !== null"
|
||||
(cancel)="closeModal()"
|
||||
(confirm)="onDeleteConfirm()"
|
||||
/>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class AdminBlogComponent implements OnInit {
|
||||
private readonly blog = inject(BlogApiService);
|
||||
|
||||
readonly posts = signal<AdminBlogPost[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly actionInFlight = signal(false);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
readonly statusMessage = signal<string | null>(null);
|
||||
|
||||
readonly modalMode = signal<ModalMode>(null);
|
||||
readonly editing = signal<AdminBlogPost | null>(null);
|
||||
readonly deleting = signal<AdminBlogPost | null>(null);
|
||||
readonly deleteOpen = signal(false);
|
||||
readonly formError = signal<string | null>(null);
|
||||
readonly deleteError = signal<string | null>(null);
|
||||
readonly saving = signal(false);
|
||||
|
||||
readonly statusBadgeClass = statusBadgeClass;
|
||||
readonly statusLabel = statusLabel;
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.loadError.set(null);
|
||||
try {
|
||||
const list = await this.blog.listAllPosts();
|
||||
this.posts.set(list);
|
||||
} catch (e: any) {
|
||||
this.loadError.set(e?.error?.message ?? e?.message ?? 'Failed to load posts');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
openCreate(): void {
|
||||
this.editing.set(null);
|
||||
this.deleting.set(null);
|
||||
this.deleteOpen.set(false);
|
||||
this.deleteError.set(null);
|
||||
this.formError.set(null);
|
||||
this.modalMode.set('create');
|
||||
this.statusMessage.set(null);
|
||||
}
|
||||
|
||||
openEdit(p: AdminBlogPost): void {
|
||||
this.deleting.set(null);
|
||||
this.deleteOpen.set(false);
|
||||
this.editing.set(p);
|
||||
this.deleteError.set(null);
|
||||
this.formError.set(null);
|
||||
this.modalMode.set('edit');
|
||||
this.statusMessage.set(null);
|
||||
}
|
||||
|
||||
openDelete(p: AdminBlogPost): void {
|
||||
this.editing.set(null);
|
||||
this.modalMode.set(null);
|
||||
this.deleting.set(p);
|
||||
this.deleteError.set(null);
|
||||
this.deleteOpen.set(true);
|
||||
this.statusMessage.set(null);
|
||||
}
|
||||
|
||||
closeModal(): void {
|
||||
this.modalMode.set(null);
|
||||
this.deleteOpen.set(false);
|
||||
this.editing.set(null);
|
||||
this.deleting.set(null);
|
||||
this.formError.set(null);
|
||||
this.deleteError.set(null);
|
||||
this.saving.set(false);
|
||||
}
|
||||
|
||||
async onFormSubmit(payload: PostFormSubmitPayload): Promise<void> {
|
||||
this.saving.set(true);
|
||||
this.formError.set(null);
|
||||
this.actionInFlight.set(true);
|
||||
try {
|
||||
const status = payload.action === 'publish' ? 'published' : 'draft';
|
||||
if (payload.mode === 'create') {
|
||||
const created = await this.blog.createPost({
|
||||
title: payload.title,
|
||||
bodyMd: payload.bodyMd,
|
||||
status,
|
||||
});
|
||||
this.statusMessage.set(
|
||||
status === 'published' ? 'Post published.' : 'Draft saved.',
|
||||
);
|
||||
this.closeModal();
|
||||
await this.load();
|
||||
// Highlight row briefly — no-op, but ensure we set posts above.
|
||||
void created;
|
||||
} else if (payload.mode === 'edit' && this.editing()) {
|
||||
const id = this.editing()!.id;
|
||||
await this.blog.updatePost(id, {
|
||||
title: payload.title,
|
||||
bodyMd: payload.bodyMd,
|
||||
status,
|
||||
});
|
||||
this.statusMessage.set(
|
||||
status === 'published' ? 'Post published.' : 'Draft saved.',
|
||||
);
|
||||
this.closeModal();
|
||||
await this.load();
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.formError.set(e?.error?.message ?? e?.message ?? 'Failed to save');
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
this.actionInFlight.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async onDeleteConfirm(): Promise<void> {
|
||||
const p = this.deleting();
|
||||
if (!p) return;
|
||||
this.actionInFlight.set(true);
|
||||
try {
|
||||
await this.blog.deletePost(p.id);
|
||||
this.statusMessage.set('Post deleted.');
|
||||
this.closeModal();
|
||||
await this.load();
|
||||
} catch (e: any) {
|
||||
this.deleteError.set(e?.error?.message ?? e?.message ?? 'Failed to delete');
|
||||
} finally {
|
||||
this.actionInFlight.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MarkdownService } from '../../core/services/markdown.service';
|
||||
import { PublicBlogPost } from '../../core/services/blog.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blog-presenter',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
.blog-item { display: block; margin: 0 0 16px 0; }
|
||||
.blog-title { margin: 0 0 4px 0; font-size: 18px; }
|
||||
.blog-date { display: block; font-size: 12px; opacity: 0.7; margin-bottom: 8px; }
|
||||
.blog-body { font-size: 14px; }
|
||||
`],
|
||||
template: `
|
||||
<article class="blog-item" [attr.data-testid]="'blog-presenter-' + post().id">
|
||||
<h2 class="blog-title" [attr.data-testid]="'blog-presenter-title-' + post().id">
|
||||
{{ post().title }}
|
||||
</h2>
|
||||
<time class="blog-date" [attr.data-testid]="'blog-presenter-date-' + post().id">
|
||||
{{ post().publishedAt | date: 'medium' }}
|
||||
</time>
|
||||
<div
|
||||
class="blog-body"
|
||||
[attr.data-testid]="'blog-presenter-body-' + post().id"
|
||||
[innerHTML]="renderedHtml()"
|
||||
></div>
|
||||
</article>
|
||||
`,
|
||||
})
|
||||
export class BlogPresenterComponent {
|
||||
readonly post = input.required<PublicBlogPost>();
|
||||
private readonly markdown = new MarkdownService();
|
||||
|
||||
renderedHtml(): string {
|
||||
return this.markdown.render(this.post()?.bodyMd ?? '');
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,60 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { BlogApiService, PublicBlogPost } from '../../core/services/blog.service';
|
||||
import { BlogPresenterComponent } from './blog-presenter.component';
|
||||
import { deriveBlogListState } from './blog.pure';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blog-page',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, BlogPresenterComponent],
|
||||
styles: [`
|
||||
.blog-page { display: flex; flex-direction: column; gap: 12px; }
|
||||
.blog-loading, .blog-error { padding: 8px; }
|
||||
.blog-error { color: var(--color-danger, #f00); }
|
||||
.blog-empty { opacity: 0.7; }
|
||||
.blog-list { display: flex; flex-direction: column; }
|
||||
`],
|
||||
template: `
|
||||
<section class="page-blog">
|
||||
<section class="blog-page" data-testid="blog-page">
|
||||
<h2>Blog</h2>
|
||||
<p>Blog posts will appear here.</p>
|
||||
|
||||
@if (state().kind === 'loading') {
|
||||
<p class="blog-loading" data-testid="blog-loading">Loading blog posts…</p>
|
||||
} @else if (state().kind === 'error') {
|
||||
<p class="blog-error" data-testid="blog-error">{{ state().message }}</p>
|
||||
} @else if (state().kind === 'empty') {
|
||||
<p class="blog-empty" data-testid="blog-empty">No announcements yet.</p>
|
||||
} @else {
|
||||
<div class="blog-list" data-testid="blog-list">
|
||||
@for (post of posts(); track post.id) {
|
||||
<app-blog-presenter [post]="post" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class BlogPage {}
|
||||
export class BlogPage implements OnInit {
|
||||
private readonly blog = inject(BlogApiService);
|
||||
|
||||
readonly posts = signal<PublicBlogPost[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
readonly state = computed(() =>
|
||||
deriveBlogListState({ loading: this.loading(), error: this.error(), count: this.posts().length }),
|
||||
);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
try {
|
||||
const list = await this.blog.listPublished();
|
||||
this.posts.set(list);
|
||||
} catch (e: any) {
|
||||
this.error.set(e?.error?.message ?? e?.message ?? 'Failed to load blog posts');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type BlogListKind = 'loading' | 'error' | 'empty' | 'list';
|
||||
|
||||
export interface BlogListState {
|
||||
kind: BlogListKind;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function deriveBlogListState(input: {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
count: number;
|
||||
}): BlogListState {
|
||||
if (input.loading) return { kind: 'loading' };
|
||||
if (input.error) return { kind: 'error', message: input.error };
|
||||
if (input.count === 0) return { kind: 'empty' };
|
||||
return { kind: 'list' };
|
||||
}
|
||||
@@ -64,6 +64,27 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.landing-blog app-blog-presenter .blog-item {
|
||||
border-top: 1px solid var(--color-surface, #eee);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.landing-blog app-blog-presenter .blog-title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.landing-blog app-blog-presenter .blog-date {
|
||||
display: block;
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.landing-blog app-blog-presenter .blog-body {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.landing-blog-empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #888);
|
||||
|
||||
@@ -20,11 +20,7 @@
|
||||
@if (landing.posts().length > 0) {
|
||||
<section class="landing-blog" data-testid="landing-blog">
|
||||
@for (post of landing.posts(); track post.id) {
|
||||
<article class="landing-blog-item">
|
||||
<h2 class="landing-blog-title">{{ post.title }}</h2>
|
||||
<time class="landing-blog-date">{{ post.publishedAt | date: 'medium' }}</time>
|
||||
<div class="landing-blog-body" [innerHTML]="markdown.render(post.bodyMd)"></div>
|
||||
</article>
|
||||
<app-blog-presenter [post]="post" />
|
||||
}
|
||||
</section>
|
||||
} @else if (!landing.loading()) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { MarkdownService } from '../../core/services/markdown.service';
|
||||
import { LandingService } from './landing.service';
|
||||
import { buildLoginFailureMessage } from './login-modal.service';
|
||||
import { passwordMatchValidator } from '../setup/setup-create-admin.validators';
|
||||
import { BlogPresenterComponent } from '../blog/blog-presenter.component';
|
||||
|
||||
type ModalMode = 'closed' | 'login' | 'register';
|
||||
|
||||
@@ -23,7 +24,7 @@ type ModalMode = 'closed' | 'login' | 'register';
|
||||
selector: 'app-landing',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
imports: [CommonModule, ReactiveFormsModule, BlogPresenterComponent],
|
||||
templateUrl: './landing.component.html',
|
||||
styleUrls: ['./landing.component.css'],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as express from 'express';
|
||||
import request from 'supertest';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
import { BlogPostEntity } from '../../backend/src/database/entities/blog-post.entity';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
async function loginAs(app: INestApplication, username: string, password: string): Promise<{
|
||||
agent: ReturnType<typeof request.agent>;
|
||||
accessToken: string;
|
||||
csrf: string;
|
||||
}> {
|
||||
const agent = request.agent(app.getHttpServer());
|
||||
await agent.get('/api/v1/auth/csrf');
|
||||
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||
const csrf = cookies.find((c: any) => c.name === 'csrf')?.value;
|
||||
const res = await agent
|
||||
.post('/api/v1/auth/login')
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ username, password })
|
||||
.expect(201);
|
||||
return { agent, accessToken: res.body.accessToken as string, csrf };
|
||||
}
|
||||
|
||||
async function createPlayer(
|
||||
app: INestApplication,
|
||||
adminToken: string,
|
||||
adminCsrf: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ agent: ReturnType<typeof request.agent>; accessToken: string; csrf: string }> {
|
||||
const adminAgent = request.agent(app.getHttpServer());
|
||||
await adminAgent.get('/api/v1/auth/csrf');
|
||||
const adminAgentCookies: any = adminAgent.jar.getCookies(CookieAccessInfo.All);
|
||||
const adminAgentCsrf = adminAgentCookies.find((c: any) => c.name === 'csrf')?.value;
|
||||
await adminAgent
|
||||
.post('/api/v1/admin/users')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminAgentCsrf)
|
||||
.send({ username, password, role: 'player' })
|
||||
.expect(201);
|
||||
return loginAs(app, username, password);
|
||||
}
|
||||
|
||||
describe('Admin blog posts API (Job 869)', () => {
|
||||
let app: INestApplication;
|
||||
let adminToken: string;
|
||||
let adminCsrf: string;
|
||||
let adminAgent: ReturnType<typeof request.agent>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
|
||||
const server = app.getHttpServer();
|
||||
await request(server).post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
|
||||
const logged = await loginAs(app, 'admin', 'Sup3rSecret!Pass');
|
||||
adminToken = logged.accessToken;
|
||||
adminCsrf = logged.csrf;
|
||||
adminAgent = logged.agent;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /api/v1/admin/blog/posts without a token returns 401', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/admin/blog/posts').expect(401);
|
||||
});
|
||||
|
||||
it('GET /api/v1/admin/blog/posts with a player JWT returns 403', async () => {
|
||||
const player = await createPlayer(app, adminToken, adminCsrf, 'player1', 'Sup3rSecret!Pass');
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/admin/blog/posts')
|
||||
.set('Authorization', `Bearer ${player.accessToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('POST with status=draft creates a draft with publishedAt=null and POST with status=published stamps publishedAt', async () => {
|
||||
const draft = await adminAgent
|
||||
.post('/api/v1/admin/blog/posts')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ title: 'Draft Post', bodyMd: 'still cooking', status: 'draft' })
|
||||
.expect(201);
|
||||
expect(draft.body.status).toBe('draft');
|
||||
expect(draft.body.publishedAt).toBeNull();
|
||||
expect(draft.body.title).toBe('Draft Post');
|
||||
expect(draft.body.id).toBeDefined();
|
||||
expect(draft.body.createdAt).toBeDefined();
|
||||
|
||||
const published = await adminAgent
|
||||
.post('/api/v1/admin/blog/posts')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ title: 'Live Post', bodyMd: '# Hello world', status: 'published' })
|
||||
.expect(201);
|
||||
expect(published.body.status).toBe('published');
|
||||
expect(published.body.publishedAt).toBeTruthy();
|
||||
expect(typeof published.body.publishedAt).toBe('string');
|
||||
});
|
||||
|
||||
it('POST with empty title returns 400 VALIDATION_FAILED', async () => {
|
||||
const res = await adminAgent
|
||||
.post('/api/v1/admin/blog/posts')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ title: ' ', bodyMd: 'x' })
|
||||
.expect(400);
|
||||
expect(res.body.code).toBe('VALIDATION_FAILED');
|
||||
});
|
||||
|
||||
it('GET /api/v1/admin/blog/posts returns drafts AND published (admin-only view)', async () => {
|
||||
const res = await adminAgent
|
||||
.get('/api/v1/admin/blog/posts')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.expect(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
// Both 'Draft Post' and 'Live Post' should appear since we just created them.
|
||||
const titles = res.body.map((p: any) => p.title);
|
||||
expect(titles).toEqual(expect.arrayContaining(['Draft Post', 'Live Post']));
|
||||
// Every row includes admin-only fields.
|
||||
for (const p of res.body) {
|
||||
expect(p.status).toBeDefined();
|
||||
expect(p.createdAt).toBeDefined();
|
||||
expect(p.updatedAt).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('PUT preserves draft state (publishedAt=null) on a draft edit; PUT to published stamps publishedAt once and preserves it on subsequent edits', async () => {
|
||||
// 1. Create a draft.
|
||||
const draft = await adminAgent
|
||||
.post('/api/v1/admin/blog/posts')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ title: 'Edit Me Draft', bodyMd: 'v1', status: 'draft' })
|
||||
.expect(201);
|
||||
const draftId = draft.body.id;
|
||||
|
||||
// 2. Edit title/body without changing status -> stays draft, publishedAt stays null.
|
||||
const edited = await adminAgent
|
||||
.put(`/api/v1/admin/blog/posts/${draftId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ title: 'Edit Me Draft (updated)', bodyMd: 'v2' })
|
||||
.expect(200);
|
||||
expect(edited.body.status).toBe('draft');
|
||||
expect(edited.body.publishedAt).toBeNull();
|
||||
expect(edited.body.bodyMd).toBe('v2');
|
||||
|
||||
// 3. Publish -> stamps publishedAt.
|
||||
const firstPublish = await adminAgent
|
||||
.put(`/api/v1/admin/blog/posts/${draftId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ status: 'published' })
|
||||
.expect(200);
|
||||
expect(firstPublish.body.status).toBe('published');
|
||||
const originalPublishedAt = firstPublish.body.publishedAt;
|
||||
expect(originalPublishedAt).toBeTruthy();
|
||||
|
||||
// 4. Wait a few ms and update body again; publishedAt must not change.
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const secondEdit = await adminAgent
|
||||
.put(`/api/v1/admin/blog/posts/${draftId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ bodyMd: 'v3 — content edit after publish' })
|
||||
.expect(200);
|
||||
expect(secondEdit.body.status).toBe('published');
|
||||
expect(secondEdit.body.publishedAt).toBe(originalPublishedAt);
|
||||
expect(secondEdit.body.bodyMd).toBe('v3 — content edit after publish');
|
||||
|
||||
// 5. Demote to draft; publishedAt must be preserved so a later re-publish does not change it.
|
||||
const demoted = await adminAgent
|
||||
.put(`/api/v1/admin/blog/posts/${draftId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ status: 'draft' })
|
||||
.expect(200);
|
||||
expect(demoted.body.status).toBe('draft');
|
||||
expect(demoted.body.publishedAt).toBe(originalPublishedAt);
|
||||
|
||||
// 6. Re-publish; publishedAt must STILL be the original timestamp.
|
||||
const republished = await adminAgent
|
||||
.put(`/api/v1/admin/blog/posts/${draftId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ status: 'published' })
|
||||
.expect(200);
|
||||
expect(republished.body.status).toBe('published');
|
||||
expect(republished.body.publishedAt).toBe(originalPublishedAt);
|
||||
});
|
||||
|
||||
it('DELETE removes the post and a subsequent DELETE returns 404', async () => {
|
||||
const created = await adminAgent
|
||||
.post('/api/v1/admin/blog/posts')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ title: 'To Delete', bodyMd: 'goodbye', status: 'draft' })
|
||||
.expect(201);
|
||||
const id = created.body.id;
|
||||
|
||||
await adminAgent
|
||||
.delete(`/api/v1/admin/blog/posts/${id}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.expect(204);
|
||||
|
||||
await adminAgent
|
||||
.delete(`/api/v1/admin/blog/posts/${id}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('public GET /api/v1/blog/posts hides drafts after admin operations', async () => {
|
||||
// Make sure a draft and a published exist (regression for public list).
|
||||
await adminAgent
|
||||
.post('/api/v1/admin/blog/posts')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ title: 'Hidden Draft', bodyMd: 'should not leak', status: 'draft' })
|
||||
.expect(201);
|
||||
|
||||
const res = await request(app.getHttpServer()).get('/api/v1/blog/posts').expect(200);
|
||||
const titles = res.body.posts.map((p: any) => p.title);
|
||||
expect(titles).not.toContain('Hidden Draft');
|
||||
// No row in the public list should ever include admin-only fields.
|
||||
for (const p of res.body.posts) {
|
||||
expect(p.status).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('PUT with empty title returns 400 VALIDATION_FAILED', async () => {
|
||||
const created = await adminAgent
|
||||
.post('/api/v1/admin/blog/posts')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ title: 'Edit Title Test', bodyMd: '', status: 'draft' })
|
||||
.expect(201);
|
||||
const id = created.body.id;
|
||||
|
||||
const res = await adminAgent
|
||||
.put(`/api/v1/admin/blog/posts/${id}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ title: '' })
|
||||
.expect(400);
|
||||
expect(res.body.code).toBe('VALIDATION_FAILED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin blog posts raw access', () => {
|
||||
let app: INestApplication;
|
||||
let adminAgent: ReturnType<typeof request.agent>;
|
||||
let adminToken: string;
|
||||
let adminCsrf: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
|
||||
const server = app.getHttpServer();
|
||||
await request(server).post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'rawadmin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
|
||||
const logged = await loginAs(app, 'rawadmin', 'Sup3rSecret!Pass');
|
||||
adminToken = logged.accessToken;
|
||||
adminCsrf = logged.csrf;
|
||||
adminAgent = logged.agent;
|
||||
|
||||
// Seed one published + one draft directly via the entity manager.
|
||||
const repo = app.get<any>(getRepositoryToken(BlogPostEntity));
|
||||
await repo.insert([
|
||||
{
|
||||
id: 'seed-pub',
|
||||
title: 'Seeded Published',
|
||||
bodyMd: '# Published',
|
||||
status: 'published',
|
||||
publishedAt: '2026-07-21T10:00:00.000Z',
|
||||
createdAt: '2026-07-20T10:00:00.000Z',
|
||||
updatedAt: '2026-07-21T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'seed-drf',
|
||||
title: 'Seeded Draft',
|
||||
bodyMd: 'wip',
|
||||
status: 'draft',
|
||||
publishedAt: null,
|
||||
createdAt: '2026-07-22T10:00:00.000Z',
|
||||
updatedAt: '2026-07-22T10:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns both seeded rows including drafts for the admin', async () => {
|
||||
const res = await adminAgent
|
||||
.get('/api/v1/admin/blog/posts')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.expect(200);
|
||||
const ids = res.body.map((p: any) => p.id);
|
||||
expect(ids).toEqual(expect.arrayContaining(['seed-pub', 'seed-drf']));
|
||||
});
|
||||
});
|
||||
@@ -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