Compare commits

..

2 Commits

Author SHA1 Message Date
26bdb65346 refactor: update S3StorageAdapter tests to use public URL for object retrieval
Some checks failed
Build and Test / run-test (20.x) (push) Failing after 1m41s
2026-04-17 00:59:13 -03:00
b9e34e590d refactor: update article deletion and retrieval methods to use external ID 2026-04-17 00:12:44 -03:00
12 changed files with 151 additions and 126 deletions

View File

@@ -1,14 +1,15 @@
import { ArticleList } from '@/ui/components/internal/article/article-list'; import { ArticleList } from '@/ui/components/internal/article/article-list';
import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton';
import { Suspense } from 'react';
const PAGE_SIZE = 4; const DEFAULT_PAGE_SIZE = 4;
type HomeProps = { type HomeProps = {
searchParams: Promise<{ page?: string; pageSize?: string }>; searchParams?: { page?: string; pageSize?: string };
}; };
const Home = async ({ searchParams }: HomeProps) => { const Home = async ({ searchParams }: HomeProps) => {
const page = Number(searchParams?.page) || 0;
const pageSize = Number(searchParams?.pageSize) || DEFAULT_PAGE_SIZE;
return ( return (
<div className='container mx-auto w-full flex-1 px-4 py-12 md:py-16'> <div className='container mx-auto w-full flex-1 px-4 py-12 md:py-16'>
<div className='mb-10 border-b border-border pb-8'> <div className='mb-10 border-b border-border pb-8'>
@@ -19,14 +20,7 @@ const Home = async ({ searchParams }: HomeProps) => {
Latest Articles Latest Articles
</h1> </h1>
</div> </div>
<Suspense <ArticleList page={page} pageSize={pageSize} />
fallback={<ArticleListSkeleton skeletonSize={PAGE_SIZE} />}
>
<ArticleList
searchParams={searchParams}
defaultPageSize={PAGE_SIZE}
/>
</Suspense>
</div> </div>
); );
}; };

View File

@@ -8,43 +8,56 @@ import {
} from '@/lib/feature/article/article.model'; } from '@/lib/feature/article/article.model';
import * as service from '@/lib/feature/article/article.service'; import * as service from '@/lib/feature/article/article.service';
import { getSessionData } from '@/lib/session/session-storage'; import { getSessionData } from '@/lib/session/session-storage';
import { TypedResult, wrap } from '@/utils/types/results'; import { TypedResult, wrap, wrapCached } from '@/utils/types/results';
import { UUIDv4 } from '@/utils/types/uuid'; import { UUIDv4 } from '@/utils/types/uuid';
import { revalidatePath } from 'next/cache'; import { revalidateTag } from 'next/cache';
export const getArticleByExternalId: ( export const getArticleByExternalId: (
externalId: UUIDv4 externalId: UUIDv4
) => Promise<TypedResult<ArticleModel | null>> = wrap( ) => Promise<TypedResult<ArticleModel | null>> = wrapCached(
async (externalId: UUIDv4): Promise<ArticleModel | null> => { async (externalId: UUIDv4): Promise<ArticleModel | null> => {
const result = await service.getArticleByExternalId(externalId); const result = await service.getArticleByExternalId(externalId);
if (!result.ok) throw result.error; if (!result.ok) throw result.error;
return result.value; return result.value;
},
{
key: (id) => [`article:${id}`],
tags: (id) => ['articles', `article:${id}`],
} }
); );
export const getArticleBySlug: ( export const getArticleBySlug: (
slug: string slug: string
) => Promise<TypedResult<ArticleModel | null>> = wrap( ) => Promise<TypedResult<ArticleModel | null>> = wrapCached(
async (slug: string): Promise<ArticleModel | null> => { async (slug: string): Promise<ArticleModel | null> => {
const result = await service.getArticleBySlug(slug); const result = await service.getArticleBySlug(slug);
if (!result.ok) throw result.error; if (!result.ok) throw result.error;
return result.value; return result.value;
},
{
key: (slug) => [`article:slug:${slug}`],
tags: (slug) => ['articles', `article:slug:${slug}`],
} }
); );
export const getArticlesPaginated: ( export const getArticlesPaginated: (
page?: number, page?: number,
pageSize?: number pageSize?: number
) => Promise<TypedResult<PaginatedArticlesResult>> = wrap( ) => Promise<TypedResult<PaginatedArticlesResult>> = wrapCached(
async ( async (
page: number = 1, page: number = 1,
pageSize: number = 10 pageSize: number = 10
): Promise<PaginatedArticlesResult> => { ): Promise<PaginatedArticlesResult> => {
// await new Promise((r) => setTimeout(r, 1000));
const result = await service.getArticlesPaginated(page, pageSize); const result = await service.getArticlesPaginated(page, pageSize);
if (!result.ok) throw result.error; if (!result.ok) throw result.error;
return result.value; return result.value;
},
{
key: (page, pageSize) => [`articles:page:${page}:${pageSize}`],
tags: (page, pageSize) => [
'articles',
`articles:page:${page}-${pageSize}`,
],
} }
); );
@@ -62,6 +75,8 @@ export const saveArticle: (
const result = await service.saveArticle(article); const result = await service.saveArticle(article);
if (!result.ok) throw result.error; if (!result.ok) throw result.error;
revalidateTag('articles', 'max');
return result.value; return result.value;
} }
); );
@@ -83,13 +98,19 @@ export const updateArticle: (
const result = await service.updateArticle(articleId, article); const result = await service.updateArticle(articleId, article);
if (!result.ok) throw result.error; if (!result.ok) throw result.error;
revalidatePath('/admin');
revalidateTag('articles', 'max');
revalidateTag(`article:${articleId}`, 'max');
revalidateTag(`article:slug:${result.value.slug}`, 'max');
return result.value; return result.value;
} }
); );
export const deleteArticle: (articleId: string) => Promise<TypedResult<void>> = export const deleteArticleByExternalId: (
wrap(async (articleId: string): Promise<void> => { externalId: UUIDv4
) => Promise<TypedResult<void>> = wrap(
async (externalId: UUIDv4): Promise<void> => {
const session = await getSessionData(); const session = await getSessionData();
if (!session || !session?.user || session?.user.role !== 'admin') { if (!session || !session?.user || session?.user.role !== 'admin') {
throw new Error( throw new Error(
@@ -97,7 +118,17 @@ export const deleteArticle: (articleId: string) => Promise<TypedResult<void>> =
); );
} }
const result = await service.deleteArticle(articleId); const getResult = await service.getArticleByExternalId(externalId);
if (!getResult.ok) throw getResult.error;
const article = getResult.value;
if (!article) throw new Error('Article not found');
const result = await service.deleteArticleByExternalId(externalId);
if (!result.ok) throw result.error; if (!result.ok) throw result.error;
revalidatePath('/admin');
}); revalidateTag('articles', 'max');
revalidateTag(`article:${externalId}`, 'max');
revalidateTag(`article:slug:${article.slug}`, 'max');
}
);

View File

@@ -155,16 +155,19 @@ export const updateArticle: (
); );
/** Deletes an article from the database. */ /** Deletes an article from the database. */
export const deleteArticle: (articleId: string) => Promise<TypedResult<void>> = export const deleteArticleByExternalId: (
wrap(async (articleId: string): Promise<void> => { externalId: UUIDv4
) => Promise<TypedResult<void>> = wrap(
async (externalId: UUIDv4): Promise<void> => {
const articleRepository = await getRepository(ArticleEntity); const articleRepository = await getRepository(ArticleEntity);
const existingArticle = await articleRepository.findOneBy({ const existingArticle = await articleRepository.findOneBy({
id: articleId, externalId: externalId,
}); });
if (!existingArticle) { if (!existingArticle) {
throw new Error(`Article with ID ${articleId} not found`); throw new Error(`Article with ExternalID ${externalId} not found`);
} }
await articleRepository.remove(existingArticle); await articleRepository.remove(existingArticle);
}); }
);

View File

@@ -1,30 +1,14 @@
import { StorageProvider } from '@/lib/storage/storage.interface'; import { StorageProvider } from '@/lib/storage/storage.interface';
import { TypedResult, wrap } from '@/utils/types/results'; import { TypedResult, wrap } from '@/utils/types/results';
import { DeleteObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import {
DeleteObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { z } from 'zod'; import { z } from 'zod';
/** /**
* Configuration for S3 storage adapter * Configuration for S3 storage adapter
*/ */

View File

@@ -62,8 +62,8 @@ export const AdminArticleCard = ({ article }: AdminArticleCardProps) => {
</Link> </Link>
</Button> </Button>
<DeleteArticleButton <DeleteArticleButton
articleId={article.id} externalID={article.externalId}
articleTitle={article.title} title={article.title}
/> />
</div> </div>
</div> </div>

View File

@@ -144,8 +144,8 @@ export const AdminArticleList = async ({
</Link> </Link>
</Button> </Button>
<DeleteArticleButton <DeleteArticleButton
articleId={article.id} externalID={article.externalId}
articleTitle={article.title} title={article.title}
/> />
</div> </div>
</TableCell> </TableCell>

View File

@@ -1,22 +1,18 @@
// 'use client';
import { getArticlesPaginated } from '@/lib/feature/article/article.external'; import { getArticlesPaginated } from '@/lib/feature/article/article.external';
import { ArticleCard } from '@/ui/components/internal/article-card'; import { ArticleCard } from '@/ui/components/internal/article-card';
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination'; import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
import { FileTextIcon } from 'lucide-react'; import { FileTextIcon } from 'lucide-react';
import { cacheTag } from 'next/cache';
type ArticleListProps = { type ArticleListProps = {
searchParams: Promise<{ page?: string; pageSize?: string }>; page: number;
defaultPageSize: number; pageSize: number;
}; };
export const ArticleList = async ({ export const ArticleList = async ({ page, pageSize }: ArticleListProps) => {
searchParams, 'use cache';
defaultPageSize,
}: ArticleListProps) => {
const { page: pageParam, pageSize: pageSizeParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const pageSize = Number(pageSizeParam) || defaultPageSize;
cacheTag('articles', `articles:page:${page}-${pageSize}`);
const paginationResult = await getArticlesPaginated(page, pageSize); const paginationResult = await getArticlesPaginated(page, pageSize);
if (!paginationResult.ok) { if (!paginationResult.ok) {

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { deleteArticle } from '@/lib/feature/article/article.external'; import { deleteArticleByExternalId } from '@/lib/feature/article/article.external';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -13,24 +13,25 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from '@/ui/components/shadcn/alert-dialog'; } from '@/ui/components/shadcn/alert-dialog';
import { Button } from '@/ui/components/shadcn/button'; import { Button } from '@/ui/components/shadcn/button';
import { UUIDv4 } from '@/utils/types/uuid';
import { Trash2Icon } from 'lucide-react'; import { Trash2Icon } from 'lucide-react';
import { useTransition } from 'react'; import { useTransition } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface DeleteArticleButtonProps { interface DeleteArticleButtonProps {
articleId: string; externalID: UUIDv4;
articleTitle: string; title: string;
} }
export const DeleteArticleButton = ({ export const DeleteArticleButton = ({
articleId, externalID,
articleTitle, title,
}: DeleteArticleButtonProps) => { }: DeleteArticleButtonProps) => {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const handleDelete = () => { const handleDelete = () => {
startTransition(async () => { startTransition(async () => {
const result = await deleteArticle(articleId); const result = await deleteArticleByExternalId(externalID);
if (!result.ok) { if (!result.ok) {
toast.error('Failed to delete article', { toast.error('Failed to delete article', {
description: result.error.message, description: result.error.message,
@@ -39,7 +40,7 @@ export const DeleteArticleButton = ({
return; return;
} }
toast.success('Article deleted', { toast.success('Article deleted', {
description: `"${articleTitle}" has been removed.`, description: `"${title}" has been removed.`,
position: 'bottom-right', position: 'bottom-right',
}); });
}); });
@@ -57,7 +58,7 @@ export const DeleteArticleButton = ({
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete article?</AlertDialogTitle> <AlertDialogTitle>Delete article?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will permanently delete &ldquo;{articleTitle} This will permanently delete &ldquo;{title}
&rdquo;. This action cannot be undone. &rdquo;. This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>

View File

@@ -1,22 +1,8 @@
import { unstable_cache } from 'next/cache';
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }; export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export type TypedResult<T> = Result<T, Error>; export type TypedResult<T> = Result<T, Error>;
export function wrapBlocking<
F extends (...args: never[]) => unknown,
E = unknown,
>(
fn: F,
mapError: (e: unknown) => E = (e) => e as E
): (...args: Parameters<F>) => Result<ReturnType<F>, E> {
return (...args) => {
try {
return { ok: true, value: fn(...args) as ReturnType<F> };
} catch (e) {
return { ok: false, error: mapError(e) };
}
};
}
export function wrap< export function wrap<
F extends (...args: never[]) => Promise<unknown>, F extends (...args: never[]) => Promise<unknown>,
E = unknown, E = unknown,
@@ -35,3 +21,43 @@ export function wrap<
} }
}; };
} }
export function wrapCached<
F extends (...args: never[]) => Promise<unknown>,
E = unknown,
>(
fn: F,
options: {
key: (...args: Parameters<F>) => string[];
tags?: (...args: Parameters<F>) => string[];
revalidate?: number;
},
mapError: (e: unknown) => E = (e) => e as E
): (...args: Parameters<F>) => Promise<Result<Awaited<ReturnType<F>>, E>> {
return async (...args: Parameters<F>) => {
try {
const cachedFn = unstable_cache(
async (...innerArgs: Parameters<F>) => {
return await fn(...innerArgs);
},
options.key(...args),
{
tags: options.tags?.(...args),
revalidate: options.revalidate,
}
);
const value = await cachedFn(...args);
return {
ok: true,
value: value as Awaited<ReturnType<F>>,
};
} catch (e) {
return {
ok: false,
error: mapError(e),
};
}
};
}

View File

@@ -1,22 +1,4 @@
type HexChar = import { z } from 'zod';
| '0'
| '1'
| '2'
| '3'
| '4'
| '5'
| '6'
| '7'
| '8'
| '9'
| 'a'
| 'b'
| 'c'
| 'd'
| 'e'
| 'f';
type UUIDv4Segment<Length extends number> =
`${HexChar extends string ? HexChar : never}${string extends `${Length}` ? never : never}`; // Simplified for brevity
export type UUIDv4 = export const UUIDv4 = z.uuid();
`${UUIDv4Segment<8>}-${UUIDv4Segment<4>}-4${UUIDv4Segment<3>}-${'8' | '9' | 'a' | 'b'}${UUIDv4Segment<3>}-${UUIDv4Segment<12>}`; export type UUIDv4 = z.infer<typeof UUIDv4>;

View File

@@ -1,13 +1,13 @@
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
import { import {
CreateArticleModel, CreateArticleModel,
UpdateArticleModel, UpdateArticleModel,
} from '@/lib/feature/article/article.model'; } from '@/lib/feature/article/article.model';
import { import {
deleteArticle, deleteArticleByExternalId,
getArticleByExternalId, getArticleByExternalId,
getArticleBySlug, getArticleBySlug,
getArticlesByAuthorId, getArticlesByAuthorId,
getArticlesPaginated,
saveArticle, saveArticle,
updateArticle, updateArticle,
} from '@/lib/feature/article/article.service'; } from '@/lib/feature/article/article.service';
@@ -259,7 +259,9 @@ describe('ArticleService', () => {
if (!saveResult.ok) return; if (!saveResult.ok) return;
expect(saveResult.value.id).toBeDefined(); expect(saveResult.value.id).toBeDefined();
const deleteResult = await deleteArticle(saveResult.value.id); const deleteResult = await deleteArticleByExternalId(
saveResult.value.externalId
);
expect(deleteResult.ok).toBe(true); expect(deleteResult.ok).toBe(true);
const getResult = await getArticleBySlug('article-to-delete'); const getResult = await getArticleBySlug('article-to-delete');
@@ -269,7 +271,7 @@ describe('ArticleService', () => {
}); });
test('cannot delete non-existing article', async () => { test('cannot delete non-existing article', async () => {
const result = await deleteArticle('9999'); const result = await deleteArticleByExternalId('9999');
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
if (result.ok) return; if (result.ok) return;

View File

@@ -1,15 +1,20 @@
import { import { S3StorageAdapter, S3StorageConfig } from '@/lib/storage/storage.adapter';
S3StorageAdapter, import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
S3StorageConfig,
} from '@/lib/storage/storage.adapter';
import {
DeleteObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import * as presigner from '@aws-sdk/s3-request-presigner'; import * as presigner from '@aws-sdk/s3-request-presigner';
import { mockClient } from 'aws-sdk-client-mock'; import { mockClient } from 'aws-sdk-client-mock';
jest.mock('@aws-sdk/s3-request-presigner'); jest.mock('@aws-sdk/s3-request-presigner');
describe('S3StorageAdapter', () => { describe('S3StorageAdapter', () => {
@@ -23,6 +28,7 @@ describe('S3StorageAdapter', () => {
region: 'us-east-1', region: 'us-east-1',
accessKey: 'test-access-key', accessKey: 'test-access-key',
secretKey: 'test-secret-key', secretKey: 'test-secret-key',
publicUrl: 'http://test.com',
}; };
beforeEach(() => { beforeEach(() => {
@@ -44,7 +50,7 @@ describe('S3StorageAdapter', () => {
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
value: `http://localhost:9000/test-bucket/${key}`, value: `http://test.com/${key}`,
}); });
}); });
@@ -55,7 +61,7 @@ describe('S3StorageAdapter', () => {
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
value: `http://localhost:9000/test-bucket/${key}`, value: `http://test.com/${key}`,
}); });
}); });
}); });