Compare commits

...

6 Commits

24 changed files with 1492 additions and 16 deletions

View File

@@ -5,7 +5,7 @@ services:
image: postgres:16 image: postgres:16
restart: always restart: always
environment: environment:
POSTGRES_DB: absolute POSTGRES_DB: local_db
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
volumes: volumes:

View File

@@ -12,7 +12,7 @@ CREATE TABLE users (
role user_role NOT NULL DEFAULT 'user', role user_role NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
external_id UUID NOT NULL DEFAULT gen_random_uuid() external_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE
); );
--end-sql`); --end-sql`);
} }

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddsArticlesTable1775010269415 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`--sql
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
cover_image_url TEXT NOT NULL,
content TEXT NOT NULL,
author_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
external_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE CASCADE
);
--end-sql`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`--sql
DROP TABLE articles;
--end-sql`);
}
}

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddsBaseTriggers1775011588385 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_articles_updated_at
BEFORE UPDATE ON articles
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER set_users_updated_at
BEFORE UPDATE on users
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
--end-sql`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`--sql
DROP TRIGGER set_articles_updated_at ON articles;
DROP TRIGGER set_users_updated_at ON users;
DROP FUNCTION set_updated_at();
--end-sql`);
}
}

46
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.0.7", "@clerk/nextjs": "^7.0.7",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
"@vercel/analytics": "^2.0.1", "@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -22,7 +23,9 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.72.0",
"shadcn": "^4.1.1", "shadcn": "^4.1.1",
"slugify": "^1.6.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@@ -1281,6 +1284,18 @@
"hono": "^4" "hono": "^4"
} }
}, },
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -4520,6 +4535,12 @@
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -13796,6 +13817,22 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-hook-form": {
"version": "7.72.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz",
"integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -14668,6 +14705,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/slugify": {
"version": "1.6.8",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.8.tgz",
"integrity": "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/sonner": { "node_modules/sonner": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",

View File

@@ -18,6 +18,7 @@
}, },
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.0.7", "@clerk/nextjs": "^7.0.7",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
"@vercel/analytics": "^2.0.1", "@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -31,7 +32,9 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.72.0",
"shadcn": "^4.1.1", "shadcn": "^4.1.1",
"slugify": "^1.6.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@@ -82,6 +85,7 @@
"^.+\\.(ts|tsx)$": "ts-jest" "^.+\\.(ts|tsx)$": "ts-jest"
}, },
"moduleNameMapper": { "moduleNameMapper": {
"\\.(png|jpg|jpeg|gif|webp|svg|ico)$": "<rootDir>/tests/setup/__mocks__/fileMock.js",
"^@/(.*)$": "<rootDir>/src/$1", "^@/(.*)$": "<rootDir>/src/$1",
"^~/(.*)$": "<rootDir>/$1" "^~/(.*)$": "<rootDir>/$1"
}, },

View File

@@ -1,12 +1,38 @@
import { siteConfig } from '@/site.config'; const AboutPage = async () => {
const Home = async () => {
return ( return (
<div className='flex flex-col items-center justify-center'> <div className='max-w-3xl mx-auto px-4 py-10'>
<h1 className='mb-4 text-4xl font-bold'>About</h1> <p className='mb-4'>Hi, Im Vitor Hideyoshi.</p>
<p className='text-lg'>Welcome {siteConfig.name}!</p>
<p className='mb-4'>
Im a software developer and Ive always enjoyed programming.
Over the years, Ive worked on all kinds of projects, from
infrastructure tools and data modeling systems to computational
physics simulations and agent-based AI tools.
</p>
<p className='mb-4'>
For me, programming is more than work. Its something I like to
explore, improve, and keep experimenting with.
</p>
<p className='mb-4'>
Im especially drawn to building things that are simple,
practical, and useful. I care about continuous improvement, both
in the systems I build and in the way I approach problems.
</p>
<p className='mb-4'>
This blog is where I share what I learn along the way, including
ideas, experiments, and lessons from real projects.
</p>
<p>
Feel free to explore my posts and check out my work on GitHub.
<br />
Hope we can learn and grow together.
</p>
</div> </div>
); );
}; };
export default Home; export default AboutPage;

View File

@@ -1,12 +1,14 @@
import { siteConfig } from '@/site.config'; import CreateArticleForm from '@/ui/components/internal/create-article-form';
const Home = async () => { const AdminPage = async () => {
return ( return (
<div className='flex flex-col items-center justify-center'> <div className='container mx-auto py-10 min-h-3/4'>
<h1 className='mb-4 text-4xl font-bold'>Admin</h1> <div className='h-full rounded-lg border border-border bg-card p-6'>
<p className='text-lg'>Welcome {siteConfig.name}!</p> <h2 className='mb-6 text-2xl font-bold'>Create New Article</h2>
<CreateArticleForm />
</div>
</div> </div>
); );
}; };
export default Home; export default AdminPage;

View File

@@ -1 +1,2 @@
export { ArticleEntity } from '@/lib/feature/article/article.entity';
export { UserEntity } from '@/lib/feature/user/user.entity'; export { UserEntity } from '@/lib/feature/user/user.entity';

View File

@@ -0,0 +1,58 @@
import { UserEntity } from '@/lib/feature/user/user.entity';
import type { Relation } from '@/utils/types/relation';
import type { UUIDv4 } from '@/utils/types/uuid';
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
@Entity('articles')
export class ArticleEntity {
@PrimaryColumn({
type: 'bigint',
primary: true,
generated: true,
})
id: string;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'varchar', length: 255, unique: true })
slug: string;
@Column({ type: 'text' })
description: string;
@Column({ name: 'cover_image_url', type: 'text' })
coverImageUrl: string;
@Column({ type: 'text' })
content: string;
@Column({ name: 'author_id', type: 'bigint' })
authorId: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author: Relation<UserEntity>;
@Column({
name: 'created_at',
type: 'timestamp with time zone',
default: () => 'now()',
})
createdAt: Date;
@Column({
name: 'updated_at',
type: 'timestamp with time zone',
default: () => 'now()',
})
updatedAt: Date;
@Column({
name: 'external_id',
type: 'uuid',
unique: true,
default: () => 'gen_random_uuid()',
})
externalId: UUIDv4;
}

View File

@@ -0,0 +1,61 @@
'use server';
import {
ArticleModel,
CreateArticleModel,
PaginatedArticlesResult,
UpdateArticleModel,
} from '@/lib/feature/article/article.model';
import * as service from '@/lib/feature/article/article.service';
import { getSessionData } from '@/lib/session/session-storage';
import { UUIDv4 } from '@/utils/types/uuid';
export const getArticleByExternalId = async (
externalId: UUIDv4
): Promise<ArticleModel | null> => {
return await service.getArticleByExternalId(externalId);
};
export const getArticleBySlug = async (
slug: string
): Promise<ArticleModel | null> => {
return await service.getArticleBySlug(slug);
};
export const getArticlesPaginated = async (
page: number = 1,
pageSize: number = 10
): Promise<PaginatedArticlesResult> => {
return await service.getArticlesPaginated(page, pageSize);
};
export const saveArticle = async (
article: CreateArticleModel
): Promise<ArticleModel> => {
const session = await getSessionData();
if (!session || !session?.user || session?.user.role !== 'admin') {
throw new Error('Unauthorized: Only admin users can save articles.');
}
article.authorId = session.user.id;
return await service.saveArticle(article);
};
export const updateArticle = async (
articleId: string,
article: UpdateArticleModel
): Promise<ArticleModel> => {
const session = await getSessionData();
if (!session || !session?.user || session?.user.role !== 'admin') {
throw new Error('Unauthorized: Only admin users can save articles.');
}
return await service.updateArticle(articleId, article);
};
export const deleteArticle = async (articleId: string): Promise<void> => {
const session = await getSessionData();
if (!session || !session?.user || session?.user.role !== 'admin') {
throw new Error('Unauthorized: Only admin users can delete articles.');
}
await service.deleteArticle(articleId);
};

View File

@@ -0,0 +1,45 @@
import { Pagination } from '@/utils/types/pagination';
import { z } from 'zod';
export const CreateArticleModel = z.object({
title: z.string(),
slug: z.string(),
description: z.string(),
coverImageUrl: z.string(),
content: z.string(),
authorId: z.string().optional(),
});
export type CreateArticleModel = z.infer<typeof CreateArticleModel>;
export const UpdateArticleModel = z.object({
title: z.string().optional(),
slug: z.string().optional(),
description: z.string().optional(),
coverImageUrl: z.string().optional(),
content: z.string().optional(),
});
export type UpdateArticleModel = z.infer<typeof UpdateArticleModel>;
export const ArticleModel = z.object({
id: z.string(),
title: z.string(),
slug: z.string(),
description: z.string(),
coverImageUrl: z.string(),
content: z.string(),
authorId: z.string(),
externalId: z.uuid(),
});
export type ArticleModel = z.infer<typeof ArticleModel>;
export const ArticleInfoModel = z.object({
externalId: z.uuid(),
title: z.string(),
slug: z.string(),
description: z.string(),
coverImageUrl: z.string(),
});
export type ArticleInfoModel = z.infer<typeof ArticleInfoModel>;
export const PaginatedArticlesResult = Pagination(ArticleModel);
export type PaginatedArticlesResult = z.infer<typeof PaginatedArticlesResult>;

View File

@@ -0,0 +1,177 @@
import { getRepository } from '@/lib/db/client';
import { ArticleEntity } from '@/lib/feature/article/article.entity';
import {
ArticleModel,
CreateArticleModel,
PaginatedArticlesResult,
UpdateArticleModel,
} from '@/lib/feature/article/article.model';
import { UUIDv4 } from '@/utils/types/uuid';
export const articleEntityToModel = (
articleEntity: ArticleEntity
): ArticleModel => {
return {
id: articleEntity.id,
title: articleEntity.title,
slug: articleEntity.slug,
description: articleEntity.description,
coverImageUrl: articleEntity.coverImageUrl,
content: articleEntity.content,
authorId: articleEntity.authorId,
externalId: articleEntity.externalId,
};
};
/** Retrieves an artible by its external ID.
* @param externalId - The external ID of the article to retrieve.
* @returns {Promise<ArticleModel | null>} The article model if found, otherwise null.
* */
export const getArticleByExternalId = async (
externalId: UUIDv4
): Promise<ArticleModel | null> => {
const articleRepository = await getRepository(ArticleEntity);
const articleEntity = await articleRepository.findOneBy({
externalId: externalId,
});
if (!articleEntity) {
return null;
}
return articleEntityToModel(articleEntity);
};
/**
* Retrieves an article by its slug.
* @param slug - The slug of the article to retrieve.
* @returns {Promise<ArticleModel | null>} The article model if found, otherwise null.
*/
export const getArticleBySlug = async (
slug: string
): Promise<ArticleModel | null> => {
const articleRepository = await getRepository(ArticleEntity);
const articleEntity = await articleRepository.findOneBy({ slug });
if (!articleEntity) {
return null;
}
return articleEntityToModel(articleEntity);
};
/**
* Retrieves all articles by a given author ID.
* @param authorId - The ID of the author.
* @returns {Promise<ArticleModel[]>} A list of article models.
*/
export const getArticlesByAuthorId = async (
authorId: string
): Promise<ArticleModel[]> => {
const articleRepository = await getRepository(ArticleEntity);
const articleEntities = await articleRepository.findBy({ authorId });
return articleEntities.map(articleEntityToModel);
};
/**
* Retrieves a paginated list of articles ordered by creation date descending.
* @param page - The page number (1-based).
* @param pageSize - The number of articles per page.
* @returns {Promise<PaginatedArticlesResult>} The paginated result.
*/
export const getArticlesPaginated = async (
page: number = 1,
pageSize: number = 10
): Promise<PaginatedArticlesResult> => {
const articleRepository = await getRepository(ArticleEntity);
const [articleEntities, total] = await articleRepository.findAndCount({
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data: articleEntities.map(articleEntityToModel),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
};
/**
* Saves a new article to the database.
* @param article - The article data to save.
* @returns {Promise<ArticleModel>} The saved article model.
* @throws {Error} If an article with the same slug already exists.
*/
export const saveArticle = async (
article: CreateArticleModel
): Promise<ArticleModel> => {
const articleRepository = await getRepository(ArticleEntity);
if (!article.authorId) {
throw new Error('Author ID is required to save an article');
}
if (!!(await articleRepository.findOneBy({ slug: article.slug }))) {
throw new Error(`Article with slug ${article.slug} already exists`);
}
const newArticle = articleRepository.create(article);
return articleEntityToModel(await articleRepository.save(newArticle));
};
/**
* Updates an existing article in the database.
* @param articleId - The ID of the article to update.
* @param article - The new article data.
* @returns {Promise<ArticleModel>} The updated article model.
* @throws {Error} If the article with the given ID does not exist.
*/
export const updateArticle = async (
articleId: string,
article: UpdateArticleModel
): Promise<ArticleModel> => {
const articleRepository = await getRepository(ArticleEntity);
const existingArticle = await articleRepository.findOneBy({
id: articleId,
});
if (!existingArticle) {
throw new Error(`Article with ID ${articleId} not found`);
}
if (!!article.title) existingArticle.title = article.title;
if (!!article.slug) existingArticle.slug = article.slug;
if (!!article.description)
existingArticle.description = article.description;
if (!!article.coverImageUrl)
existingArticle.coverImageUrl = article.coverImageUrl;
if (!!article.content) existingArticle.content = article.content;
return articleEntityToModel(await articleRepository.save(existingArticle));
};
/**
* Deletes an article from the database.
* @param articleId - The ID of the article to delete.
* @throws {Error} If the article with the given ID does not exist.
*/
export const deleteArticle = async (articleId: string): Promise<void> => {
const articleRepository = await getRepository(ArticleEntity);
const existingArticle = await articleRepository.findOneBy({
id: articleId,
});
if (!existingArticle) {
throw new Error(`Article with ID ${articleId} not found`);
}
await articleRepository.remove(existingArticle);
};

View File

@@ -0,0 +1,197 @@
'use client';
import { saveArticle } from '@/lib/feature/article/article.external';
import { Button } from '@/ui/components/shadcn/button';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from '@/ui/components/shadcn/field';
import { Input } from '@/ui/components/shadcn/input';
import {
InputGroup,
InputGroupTextarea,
} from '@/ui/components/shadcn/input-group';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import slugify from 'slugify';
import { toast } from 'sonner';
import { z } from 'zod';
export const CreateArticleForm = () => {
const formSchema = z.object({
title: z.string().min(3).max(255),
slug: z.string().min(3),
description: z.string().min(10),
coverImageUrl: z.string().url(),
content: z.string().min(10),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
slug: '',
description: '',
coverImageUrl: '',
content: '',
},
});
const title = useWatch({
control: form.control,
name: 'title',
});
useEffect(() => {
if (!title) return;
form.setValue('slug', slugify(title).toLowerCase());
}, [form, title]);
async function onSubmit(data: z.infer<typeof formSchema>) {
try {
const result = await saveArticle(data);
toast.success('Article created successfully!', {
description: `Article "${result.title}" has been created.`,
position: 'bottom-right',
});
form.reset();
} catch (error) {
toast.error('Failed to create article', {
description:
error instanceof Error
? error.message
: 'An error occurred',
position: 'bottom-right',
});
}
}
return (
<form id='form-create-article' onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
name='title'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-title'>
Title
</FieldLabel>
<Input
{...field}
id='form-create-article-title'
aria-invalid={fieldState.invalid}
placeholder='Article title'
autoComplete='off'
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='slug'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-slug'>
Slug
</FieldLabel>
<Input
{...field}
id='form-create-article-slug'
aria-invalid={fieldState.invalid}
placeholder='article-slug'
autoComplete='off'
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='description'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-description'>
Description
</FieldLabel>
<InputGroup>
<InputGroupTextarea
{...field}
id='form-create-article-description'
placeholder='A simple but nice description of the article here.'
rows={3}
className='min-h-24 resize-none'
aria-invalid={fieldState.invalid}
/>
</InputGroup>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='coverImageUrl'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-cover-image-url'>
Cover Image URL
</FieldLabel>
<Input
{...field}
id='form-create-article-cover-image-url'
aria-invalid={fieldState.invalid}
placeholder='https://example.com/image.jpg'
type='url'
autoComplete='off'
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='content'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-content'>
Content
</FieldLabel>
<InputGroup>
<InputGroupTextarea
{...field}
id='form-create-article-content'
placeholder='Write your article content here...'
rows={12}
className='min-h-48 resize-none font-mono text-sm'
aria-invalid={fieldState.invalid}
/>
</InputGroup>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
<div className='flex w-full justify-end'>
<Button type='submit' className='mt-6'>
Create Article
</Button>
</div>
</form>
);
};
export default CreateArticleForm;

View File

@@ -24,7 +24,7 @@ const buttonVariants = cva(
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3', lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8', icon: 'size-8',
'icon-xs': 'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",

View File

@@ -0,0 +1,102 @@
import { cn } from '@/ui/components/shadcn/lib/utils';
import * as React from 'react';
function Card({
className,
size = 'default',
...props
}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
return (
<div
data-slot='card'
data-size={size}
className={cn(
'group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-header'
className={cn(
'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3',
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-title'
className={cn(
'font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
className
)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-description'
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-action'
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-content'
className={cn('px-4 group-data-[size=sm]/card:px-3', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-footer'
className={cn(
'flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3',
className
)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,237 @@
'use client';
import { Label } from '@/ui/components/shadcn/label';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Separator } from '@/ui/components/shadcn/separator';
import { cva, type VariantProps } from 'class-variance-authority';
import { useMemo } from 'react';
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot='field-set'
className={cn(
'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot='field-legend'
data-variant={variant}
className={cn(
'mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base',
className
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-group'
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4',
className
)}
{...props}
/>
);
}
const fieldVariants = cva(
'group/field flex w-full gap-2 data-[invalid=true]:text-destructive',
{
variants: {
orientation: {
vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
horizontal:
'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
responsive:
'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
},
},
defaultVariants: {
orientation: 'vertical',
},
}
);
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role='group'
data-slot='field'
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-content'
className={cn(
'group/field-content flex flex-1 flex-col gap-0.5 leading-snug',
className
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot='field-label'
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
className
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-label'
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot='field-description'
className={cn(
'text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5',
'last:mt-0 nth-last-2:-mt-1',
'[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
className
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot='field-separator'
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className
)}
{...props}
>
<Separator className='absolute inset-0 top-1/2' />
{children && (
<span
className='relative mx-auto block w-fit bg-background px-2 text-muted-foreground'
data-slot='field-separator-content'
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className='ml-4 flex list-disc flex-col gap-1'>
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role='alert'
data-slot='field-error'
className={cn('text-sm font-normal text-destructive', className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};

View File

@@ -0,0 +1,155 @@
'use client';
import { Button } from '@/ui/components/shadcn/button';
import { Input } from '@/ui/components/shadcn/input';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Textarea } from '@/ui/components/shadcn/textarea';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='input-group'
role='group'
className={cn(
'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
className
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
'inline-start':
'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]',
'inline-end':
'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]',
'block-start':
'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
'block-end':
'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
},
},
defaultVariants: {
align: 'inline-start',
},
}
);
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role='group'
data-slot='input-group-addon'
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return;
}
e.currentTarget.parentElement?.querySelector('input')?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
'flex items-center gap-2 text-sm shadow-none',
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: '',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
}
);
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
return (
<Input
data-slot='input-group-control'
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
className
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot='input-group-control'
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
className
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};

View File

@@ -0,0 +1,18 @@
import { cn } from '@/ui/components/shadcn/lib/utils';
import * as React from 'react';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot='input'
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,23 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Label as LabelPrimitive } from 'radix-ui';
import * as React from 'react';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot='label'
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,17 @@
import { cn } from '@/ui/components/shadcn/lib/utils';
import * as React from 'react';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot='textarea'
className={cn(
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
export const Pagination = <ItemType extends z.ZodTypeAny>(
itemType: ItemType
) => {
return z.object({
data: z.array(itemType),
total: z.number(),
page: z.number(),
pageSize: z.number(),
totalPages: z.number(),
});
};

View File

@@ -0,0 +1,232 @@
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
import {
CreateArticleModel,
UpdateArticleModel,
} from '@/lib/feature/article/article.model';
import {
deleteArticle,
getArticleByExternalId,
getArticleBySlug,
getArticlesByAuthorId,
saveArticle,
updateArticle,
} from '@/lib/feature/article/article.service';
import { CreateUserModel } from '@/lib/feature/user/user.model';
import { saveUser } from '@/lib/feature/user/user.service';
import { UUIDv4 } from '@/utils/types/uuid';
import { AbstractStartedContainer } from 'testcontainers';
import { startTestDB } from '~/tests/setup/setup-db';
jest.mock('@clerk/nextjs/server', () => ({
clerkClient: jest.fn(),
}));
describe('ArticleService', () => {
let container: AbstractStartedContainer;
let authorId: string;
beforeAll(async () => {
container = await startTestDB();
const author: CreateUserModel = {
name: 'Article Author',
email: 'author@email.com',
role: 'admin',
};
const savedAuthor = await saveUser(author);
authorId = savedAuthor.id;
}, 1_000_000);
afterAll(async () => {
await container.stop();
}, 1_000_000);
test('can save article', async () => {
const articleToSave: CreateArticleModel = {
title: 'Test Article',
slug: 'test-article',
description: 'A test article description',
coverImageUrl: 'https://example.com/cover.png',
content: 'This is the article content.',
authorId: authorId,
};
const savedArticle = await saveArticle(articleToSave);
expect(savedArticle.id).toBeDefined();
expect(savedArticle.title).toBe(articleToSave.title);
expect(savedArticle.slug).toBe(articleToSave.slug);
expect(savedArticle.description).toBe(articleToSave.description);
expect(savedArticle.coverImageUrl).toBe(articleToSave.coverImageUrl);
expect(savedArticle.content).toBe(articleToSave.content);
expect(savedArticle.authorId).toBe(authorId);
expect(savedArticle.externalId).toBeDefined();
});
test('cannot save article with existing slug', async () => {
const articleToSave: CreateArticleModel = {
title: 'Duplicate Slug Article',
slug: 'test-article',
description: 'Another article with the same slug',
coverImageUrl: 'https://example.com/cover2.png',
content: 'Duplicate content.',
authorId: authorId,
};
await expect(saveArticle(articleToSave)).rejects.toThrow(
`Article with slug ${articleToSave.slug} already exists`
);
});
test('can getArticleBySlug', async () => {
const article = await getArticleBySlug('test-article');
expect(article).toBeDefined();
expect(article?.slug).toBe('test-article');
expect(article?.title).toBe('Test Article');
expect(article?.authorId).toBe(authorId);
expect(article?.externalId).toBeDefined();
});
test('cannot getArticleBySlug with non-existing slug', async () => {
await expect(getArticleBySlug('non-existing-slug')).resolves.toBeNull();
});
test('can getArticlesByAuthorId', async () => {
const articles = await getArticlesByAuthorId(authorId);
expect(articles).toBeDefined();
expect(articles.length).toBeGreaterThanOrEqual(1);
expect(articles[0].authorId).toBe(authorId);
});
test('getArticlesByAuthorId returns empty for non-existing author', async () => {
const articles = await getArticlesByAuthorId('9999');
expect(articles).toBeDefined();
expect(articles.length).toBe(0);
});
test('can getArticleByExternalId', async () => {
const article = await getArticleBySlug('test-article');
expect(article).toBeDefined();
const foundArticle = await getArticleByExternalId(
article!.externalId as UUIDv4
);
expect(foundArticle).toBeDefined();
expect(foundArticle?.id).toBe(article!.id);
expect(foundArticle?.title).toBe(article!.title);
expect(foundArticle?.slug).toBe(article!.slug);
expect(foundArticle?.externalId).toBe(article!.externalId);
});
test('getArticleByExternalId returns null for non-existing id', async () => {
const result = await getArticleByExternalId(
'00000000-0000-4000-a000-000000000000' as UUIDv4
);
expect(result).toBeNull();
});
test('can getArticlesPaginated with defaults', async () => {
const result = await getArticlesPaginated();
expect(result).toBeDefined();
expect(result.data.length).toBeGreaterThanOrEqual(1);
expect(result.page).toBe(1);
expect(result.pageSize).toBe(10);
expect(result.total).toBeGreaterThanOrEqual(1);
expect(result.totalPages).toBeGreaterThanOrEqual(1);
});
test('can getArticlesPaginated with custom page size', async () => {
// Save extra articles to test pagination
for (let i = 0; i < 3; i++) {
await saveArticle({
title: `Paginated Article ${i}`,
slug: `paginated-article-${i}`,
description: `Description ${i}`,
coverImageUrl: `https://example.com/cover-${i}.png`,
content: `Content ${i}`,
authorId: authorId,
});
}
const firstPage = await getArticlesPaginated(1, 2);
expect(firstPage.data.length).toBe(2);
expect(firstPage.page).toBe(1);
expect(firstPage.pageSize).toBe(2);
expect(firstPage.total).toBeGreaterThanOrEqual(4);
expect(firstPage.totalPages).toBeGreaterThanOrEqual(2);
const secondPage = await getArticlesPaginated(2, 2);
expect(secondPage.data.length).toBe(2);
expect(secondPage.page).toBe(2);
expect(secondPage.pageSize).toBe(2);
});
test('can getArticlesPaginated returns empty on out-of-range page', async () => {
const result = await getArticlesPaginated(999, 10);
expect(result.data.length).toBe(0);
expect(result.page).toBe(999);
expect(result.total).toBeGreaterThanOrEqual(1);
});
test('can update article', async () => {
const dataToUpdate: UpdateArticleModel = {
title: 'Updated Article Title',
description: 'Updated description',
};
const article = await getArticleBySlug('test-article');
expect(article).toBeDefined();
const updatedArticle = await updateArticle(article!.id, dataToUpdate);
expect(updatedArticle).toBeDefined();
expect(updatedArticle.id).toBe(article!.id);
expect(updatedArticle.title).toBe(dataToUpdate.title);
expect(updatedArticle.description).toBe(dataToUpdate.description);
expect(updatedArticle.slug).toBe(article!.slug);
expect(updatedArticle.content).toBe(article!.content);
expect(updatedArticle.externalId).toBeDefined();
});
test('cannot update non-existing article', async () => {
const dataToUpdate: UpdateArticleModel = {
title: 'Updated Article Title',
};
await expect(updateArticle('9999', dataToUpdate)).rejects.toThrow(
`Article with ID 9999 not found`
);
});
test('can delete article', async () => {
const articleToSave: CreateArticleModel = {
title: 'Article to Delete',
slug: 'article-to-delete',
description: 'This article will be deleted',
coverImageUrl: 'https://example.com/delete-cover.png',
content: 'Content to delete.',
authorId: authorId,
};
const savedArticle = await saveArticle(articleToSave);
expect(savedArticle.id).toBeDefined();
await deleteArticle(savedArticle.id);
const deletedArticle = await getArticleBySlug('article-to-delete');
expect(deletedArticle).toBeNull();
});
test('cannot delete non-existing article', async () => {
await expect(deleteArticle('9999')).rejects.toThrow(
`Article with ID 9999 not found`
);
});
});

View File

@@ -0,0 +1 @@
module.exports = 'test-file-stub';