registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
# Review package: 42d603b..HEAD
|
||||
|
||||
## Commits
|
||||
d3b5e67 feat: layer sqlite e repository posts con test
|
||||
|
||||
## Files changed
|
||||
src/lib/db.ts | 47 ++++++++++++++++++++++++++++++++++
|
||||
src/lib/posts.ts | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
tests/posts.test.ts | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
3 files changed, 195 insertions(+)
|
||||
|
||||
## Diff
|
||||
diff --git a/src/lib/db.ts b/src/lib/db.ts
|
||||
new file mode 100644
|
||||
index 0000000..537d0aa
|
||||
--- /dev/null
|
||||
+++ b/src/lib/db.ts
|
||||
@@ -0,0 +1,47 @@
|
||||
+import Database from 'better-sqlite3';
|
||||
+import { mkdirSync } from 'node:fs';
|
||||
+import { dirname } from 'node:path';
|
||||
+
|
||||
+const SCHEMA = `
|
||||
+CREATE TABLE IF NOT EXISTS posts (
|
||||
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
+ title TEXT NOT NULL,
|
||||
+ slug TEXT NOT NULL UNIQUE,
|
||||
+ category TEXT NOT NULL,
|
||||
+ excerpt TEXT NOT NULL DEFAULT '',
|
||||
+ body_html TEXT NOT NULL DEFAULT '',
|
||||
+ cover TEXT,
|
||||
+ draft INTEGER NOT NULL DEFAULT 1,
|
||||
+ published_at TEXT,
|
||||
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
+);
|
||||
+CREATE TABLE IF NOT EXISTS users (
|
||||
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
+ username TEXT NOT NULL UNIQUE,
|
||||
+ password_hash TEXT NOT NULL
|
||||
+);
|
||||
+CREATE TABLE IF NOT EXISTS sessions (
|
||||
+ token TEXT PRIMARY KEY,
|
||||
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
+ expires_at TEXT NOT NULL
|
||||
+);
|
||||
+CREATE INDEX IF NOT EXISTS idx_posts_pub ON posts (draft, published_at DESC);
|
||||
+`;
|
||||
+
|
||||
+export function createDb(path?: string): Database.Database {
|
||||
+ const p = path ?? process.env.DB_PATH ?? 'data/insanitylab.db';
|
||||
+ if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
|
||||
+ const db = new Database(p);
|
||||
+ db.pragma('journal_mode = WAL');
|
||||
+ db.pragma('foreign_keys = ON');
|
||||
+ db.exec(SCHEMA);
|
||||
+ return db;
|
||||
+}
|
||||
+
|
||||
+let singleton: Database.Database | null = null;
|
||||
+
|
||||
+export function getDb(): Database.Database {
|
||||
+ if (!singleton) singleton = createDb();
|
||||
+ return singleton;
|
||||
+}
|
||||
diff --git a/src/lib/posts.ts b/src/lib/posts.ts
|
||||
new file mode 100644
|
||||
index 0000000..dad9159
|
||||
--- /dev/null
|
||||
+++ b/src/lib/posts.ts
|
||||
@@ -0,0 +1,74 @@
|
||||
+import type Database from 'better-sqlite3';
|
||||
+
|
||||
+export interface Post {
|
||||
+ id: number; title: string; slug: string; category: string; excerpt: string;
|
||||
+ body_html: string; cover: string | null; draft: number;
|
||||
+ published_at: string | null; created_at: string; updated_at: string;
|
||||
+}
|
||||
+
|
||||
+export interface PostInput {
|
||||
+ title: string; slug: string; category: string; excerpt: string;
|
||||
+ body_html: string; cover?: string | null; draft: boolean;
|
||||
+}
|
||||
+
|
||||
+export function createPost(db: Database.Database, input: PostInput): number {
|
||||
+ const res = db.prepare(
|
||||
+ `INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, published_at)
|
||||
+ VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft,
|
||||
+ CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)`
|
||||
+ ).run({ ...input, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
|
||||
+ return Number(res.lastInsertRowid);
|
||||
+}
|
||||
+
|
||||
+export function updatePost(db: Database.Database, id: number, input: PostInput): void {
|
||||
+ db.prepare(
|
||||
+ `UPDATE posts SET title = @title, slug = @slug, category = @category,
|
||||
+ excerpt = @excerpt, body_html = @body_html, cover = @cover, draft = @draft,
|
||||
+ published_at = CASE WHEN @draft = 0 AND published_at IS NULL THEN datetime('now') ELSE published_at END,
|
||||
+ updated_at = datetime('now')
|
||||
+ WHERE id = @id`
|
||||
+ ).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
|
||||
+}
|
||||
+
|
||||
+export function deletePost(db: Database.Database, id: number): void {
|
||||
+ db.prepare('DELETE FROM posts WHERE id = ?').run(id);
|
||||
+}
|
||||
+
|
||||
+export function getPostById(db: Database.Database, id: number): Post | undefined {
|
||||
+ return db.prepare('SELECT * FROM posts WHERE id = ?').get(id) as Post | undefined;
|
||||
+}
|
||||
+
|
||||
+export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined {
|
||||
+ return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0').get(slug) as Post | undefined;
|
||||
+}
|
||||
+
|
||||
+export function listPublished(
|
||||
+ db: Database.Database,
|
||||
+ opts: { category?: string; page?: number; perPage?: number } = {},
|
||||
+): { items: Post[]; total: number } {
|
||||
+ const { category, page = 1, perPage = 9 } = opts;
|
||||
+ const where = category ? 'WHERE draft = 0 AND category = @category' : 'WHERE draft = 0';
|
||||
+ const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`)
|
||||
+ .get({ category }) as { n: number }).n;
|
||||
+ const items = db.prepare(
|
||||
+ `SELECT * FROM posts ${where} ORDER BY published_at DESC LIMIT @limit OFFSET @offset`
|
||||
+ ).all({ category, limit: perPage, offset: (page - 1) * perPage }) as Post[];
|
||||
+ return { items, total };
|
||||
+}
|
||||
+
|
||||
+export function listAllPosts(db: Database.Database): Post[] {
|
||||
+ return db.prepare('SELECT * FROM posts ORDER BY updated_at DESC').all() as Post[];
|
||||
+}
|
||||
+
|
||||
+export function listRecent(db: Database.Database, limit = 4): Post[] {
|
||||
+ return db.prepare(
|
||||
+ 'SELECT * FROM posts WHERE draft = 0 ORDER BY published_at DESC LIMIT ?'
|
||||
+ ).all(limit) as Post[];
|
||||
+}
|
||||
+
|
||||
+export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] {
|
||||
+ return db.prepare(
|
||||
+ `SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count
|
||||
+ FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC`
|
||||
+ ).all() as { month: string; count: number }[];
|
||||
+}
|
||||
diff --git a/tests/posts.test.ts b/tests/posts.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..ad373fa
|
||||
--- /dev/null
|
||||
+++ b/tests/posts.test.ts
|
||||
@@ -0,0 +1,74 @@
|
||||
+import { describe, it, expect, beforeEach } from 'vitest';
|
||||
+import type Database from 'better-sqlite3';
|
||||
+import { createDb } from '../src/lib/db';
|
||||
+import {
|
||||
+ createPost, updatePost, deletePost, getPostById, getPublishedBySlug,
|
||||
+ listPublished, listAllPosts, listRecent, listArchiveMonths, type PostInput,
|
||||
+} from '../src/lib/posts';
|
||||
+
|
||||
+let db: Database.Database;
|
||||
+beforeEach(() => { db = createDb(':memory:'); });
|
||||
+
|
||||
+const base: PostInput = {
|
||||
+ title: 'Titolo', slug: 'titolo', category: 'Contenuti educativi',
|
||||
+ excerpt: 'estratto', body_html: '<p>corpo</p>', cover: null, draft: false,
|
||||
+};
|
||||
+
|
||||
+describe('posts repository', () => {
|
||||
+ it('crea e rilegge un post pubblicato', () => {
|
||||
+ const id = createPost(db, base);
|
||||
+ const post = getPostById(db, id)!;
|
||||
+ expect(post.title).toBe('Titolo');
|
||||
+ expect(post.draft).toBe(0);
|
||||
+ expect(post.published_at).not.toBeNull();
|
||||
+ });
|
||||
+
|
||||
+ it('bozza non ha published_at e non appare nelle liste pubbliche', () => {
|
||||
+ const id = createPost(db, { ...base, slug: 'bozza', draft: true });
|
||||
+ expect(getPostById(db, id)!.published_at).toBeNull();
|
||||
+ expect(getPublishedBySlug(db, 'bozza')).toBeUndefined();
|
||||
+ expect(listPublished(db).total).toBe(0);
|
||||
+ expect(listAllPosts(db)).toHaveLength(1);
|
||||
+ });
|
||||
+
|
||||
+ it('slug duplicato lancia', () => {
|
||||
+ createPost(db, base);
|
||||
+ expect(() => createPost(db, base)).toThrow();
|
||||
+ });
|
||||
+
|
||||
+ it('update imposta published_at alla prima pubblicazione e lo conserva', () => {
|
||||
+ const id = createPost(db, { ...base, draft: true });
|
||||
+ updatePost(db, id, { ...base, draft: false });
|
||||
+ const first = getPostById(db, id)!.published_at;
|
||||
+ expect(first).not.toBeNull();
|
||||
+ updatePost(db, id, { ...base, title: 'Nuovo', draft: false });
|
||||
+ expect(getPostById(db, id)!.published_at).toBe(first);
|
||||
+ expect(getPostById(db, id)!.title).toBe('Nuovo');
|
||||
+ });
|
||||
+
|
||||
+ it('listPublished filtra per categoria e pagina', () => {
|
||||
+ for (let i = 0; i < 12; i++) {
|
||||
+ createPost(db, { ...base, slug: `post-${i}`, category: i % 2 ? 'Contenuti educativi' : 'Eventi & Presidi' });
|
||||
+ }
|
||||
+ const all = listPublished(db, { page: 1, perPage: 9 });
|
||||
+ expect(all.total).toBe(12);
|
||||
+ expect(all.items).toHaveLength(9);
|
||||
+ const edu = listPublished(db, { category: 'Contenuti educativi' });
|
||||
+ expect(edu.total).toBe(6);
|
||||
+ });
|
||||
+
|
||||
+ it('delete rimuove', () => {
|
||||
+ const id = createPost(db, base);
|
||||
+ deletePost(db, id);
|
||||
+ expect(getPostById(db, id)).toBeUndefined();
|
||||
+ });
|
||||
+
|
||||
+ it('listRecent e archivio mesi', () => {
|
||||
+ createPost(db, base);
|
||||
+ expect(listRecent(db, 4)).toHaveLength(1);
|
||||
+ const months = listArchiveMonths(db);
|
||||
+ expect(months).toHaveLength(1);
|
||||
+ expect(months[0].count).toBe(1);
|
||||
+ expect(months[0].month).toMatch(/^\d{4}-\d{2}$/);
|
||||
+ });
|
||||
+});
|
||||
Reference in New Issue
Block a user