feature/adds-admin-add-article #1

Merged
HideyoshiNakazone merged 21 commits from feature/adds-admin-add-article into main 2026-04-11 04:30:42 +00:00
14 changed files with 319 additions and 309 deletions
Showing only changes of commit 4b1bd056fc - Show all commits

View File

@@ -1,17 +0,0 @@
name: hideyoshi-blog
services:
postgres:
image: postgres:16
restart: always
environment:
POSTGRES_DB: local_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- db_data:/var/lib/postgresql/data
ports:
- '5332:5432'
volumes:
db_data:

37
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
name: hideyoshi-blog
services:
postgres:
image: postgres:16
restart: always
environment:
POSTGRES_DB: local_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- db_data:/var/lib/postgresql/data
ports:
- '5332:5432'
networks:
- internal
storage:
image: rustfs/rustfs:latest
restart: always
environment:
RUSTFS_ACCESS_KE: rustfsadmin
RUSTFS_SECRET_KE: rustfsadmin
ports:
- '9000:9000'
- '9001:9001'
volumes:
- storage_data:/data
networks:
- internal
volumes:
db_data:
storage_data:
networks:
internal:

27
docker/garage.toml Normal file
View File

@@ -0,0 +1,27 @@
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 1
# idk why rcp_ is needed without replication but ok
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "617a87858dc8d608dfb82db3ebc933b05687fe876c7bc0520afd2afb786c6d50"
compression_level = 2
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = "api.s3.su6.nl"
[s3_web]
bind_addr = "[::]:3902"
root_domain = "web.s3.su6.nl"
index = "index.html"
[admin]
api_bind_addr = "0.0.0.0:3903"
metrics_token = "617a87858dc8d608dfb82db3ebc933b05687fe876c7bc0520afd2afb786c6d50"
admin_token = "617a87858dc8d608dfb82db3ebc933b05687fe876c7bc0520afd2afb786c6d50"

35
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1028.0", "@aws-sdk/client-s3": "^3.1028.0",
"@aws-sdk/s3-request-presigner": "^3.1028.0",
"@clerk/nextjs": "^7.0.7", "@clerk/nextjs": "^7.0.7",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
@@ -772,6 +773,25 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.1028.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1028.0.tgz",
"integrity": "sha512-T46EyIIHUaF/I2zhjtSUO4/87QdhqobClCscJawrxe2h/8nZJoO3DQDVZ4tMDl5UV/B5SPz6+xwki8jGylTF4Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/signature-v4-multi-region": "^3.996.16",
"@aws-sdk/types": "^3.973.7",
"@aws-sdk/util-format-url": "^3.972.9",
"@smithy/middleware-endpoint": "^4.4.29",
"@smithy/protocol-http": "^5.3.13",
"@smithy/smithy-client": "^4.12.9",
"@smithy/types": "^4.14.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": { "node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.16", "version": "3.996.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.16.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.16.tgz",
@@ -848,6 +868,21 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@aws-sdk/util-format-url": {
"version": "3.972.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.9.tgz",
"integrity": "sha512-fNJXHrs0ZT7Wx0KGIqKv7zLxlDXt2vqjx9z6oKUQFmpE5o4xxnSryvVHfHpIifYHWKz94hFccIldJ0YSZjlCBw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.7",
"@smithy/querystring-builder": "^4.2.13",
"@smithy/types": "^4.14.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/util-locate-window": { "node_modules/@aws-sdk/util-locate-window": {
"version": "3.965.5", "version": "3.965.5",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz",

View File

@@ -18,6 +18,7 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1028.0", "@aws-sdk/client-s3": "^3.1028.0",
"@aws-sdk/s3-request-presigner": "^3.1028.0",
"@clerk/nextjs": "^7.0.7", "@clerk/nextjs": "^7.0.7",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
@@ -48,9 +49,9 @@
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^20", "@types/node": "^20",
"aws-sdk-client-mock": "^4.0.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"aws-sdk-client-mock": "^4.0.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.1", "eslint-config-next": "16.2.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",

View File

@@ -1,69 +1,25 @@
import { import { StorageProvider } from '@/lib/storage/storage.interface';
StorageProvider, import { TypedResult, wrap } from '@/utils/types/results';
StorageResult,
} from '@/lib/storage/storage.interface';
import { import {
DeleteObjectCommand, DeleteObjectCommand,
ObjectCannedACL,
PutObjectCommand, PutObjectCommand,
S3Client, S3Client,
} from '@aws-sdk/client-s3'; } from '@aws-sdk/client-s3';
import { promises as fs } from 'fs'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import path from 'path'; import { z } from 'zod';
/** /**
* Local file system storage adapter * Configuration for S3 storage adapter
* Saves files to the public/uploads directory and returns local paths
*/ */
export class LocalStorageAdapter implements StorageProvider { export const S3StorageConfig = z.object({
private readonly uploadsDir: string; endpoint: z.string(),
bucket: z.string(),
constructor(uploadsDir: string = 'public/uploads') { region: z.string(),
this.uploadsDir = uploadsDir; accessKey: z.string(),
} secretKey: z.string(),
});
async put( export type S3StorageConfig = z.infer<typeof S3StorageConfig>;
key: string,
file: Buffer | Blob,
_contentType: string
): Promise<StorageResult> {
// Ensure directory exists
await fs.mkdir(this.uploadsDir, { recursive: true });
// Convert Blob to Buffer if needed
const buffer =
file instanceof Blob ? Buffer.from(await file.arrayBuffer()) : file;
// Write file to disk
const filePath = path.join(this.uploadsDir, key);
await fs.writeFile(filePath, buffer);
// Return local path as public URL
const publicUrl = `/${this.uploadsDir}/${key}`;
return {
type: 'local',
provider: null,
key,
publicUrl,
};
}
async delete(key: string): Promise<void> {
const filePath = path.join(this.uploadsDir, key);
try {
await fs.unlink(filePath);
} catch (error) {
// File might not exist, silently ignore
if (
error instanceof Error &&
'code' in error &&
error.code !== 'ENOENT'
) {
throw error;
}
}
}
}
/** /**
* AWS S3 storage adapter * AWS S3 storage adapter
@@ -71,68 +27,71 @@ export class LocalStorageAdapter implements StorageProvider {
*/ */
export class S3StorageAdapter implements StorageProvider { export class S3StorageAdapter implements StorageProvider {
private readonly s3Client: S3Client; private readonly s3Client: S3Client;
private readonly endpoint: string;
private readonly bucketName: string; private readonly bucketName: string;
private readonly region: string;
constructor( readonly get: (
bucketName: string, ...args: Parameters<StorageProvider['get']>
region: string = 'us-east-1', ) => Promise<TypedResult<string>>;
s3Client?: S3Client readonly put: (
) { ...args: Parameters<StorageProvider['put']>
this.bucketName = bucketName; ) => Promise<TypedResult<string>>;
this.region = region; readonly delete: (
...args: Parameters<StorageProvider['delete']>
) => Promise<TypedResult<void>>;
constructor(config: S3StorageConfig, s3Client?: S3Client) {
this.endpoint = config.endpoint;
this.bucketName = config.bucket;
this.s3Client = this.s3Client =
s3Client || s3Client ||
new S3Client({ new S3Client({
region: this.region, endpoint: config.endpoint,
region: config.region,
forcePathStyle: true,
credentials: { credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', accessKeyId: config.accessKey,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '', secretAccessKey: config.secretKey,
}, },
}); });
this.get = wrap(this._get.bind(this));
this.put = wrap(this._put.bind(this));
this.delete = wrap(this._delete.bind(this));
} }
async put( private async _get(key: string): Promise<string> {
key: string, return `${this.endpoint}/${this.bucketName}/${key}`;
file: Buffer | Blob, }
contentType: string
): Promise<StorageResult> {
// Convert Blob to Buffer if needed
const buffer =
file instanceof Blob ? Buffer.from(await file.arrayBuffer()) : file;
// Upload to S3 private async _put(key: string, contentType: string): Promise<string> {
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
ContentType: contentType,
ACL: ObjectCannedACL.public_read,
});
return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 });
}
private async _delete(key: string): Promise<void> {
await this.s3Client.send( await this.s3Client.send(
new PutObjectCommand({ new DeleteObjectCommand({
Bucket: this.bucketName, Bucket: this.bucketName,
Key: key, Key: key,
Body: buffer,
ContentType: contentType,
}) })
); );
// Generate public URL
const publicUrl = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
return {
type: 's3',
provider: 'aws-s3',
key,
publicUrl,
};
}
async delete(key: string): Promise<void> {
try {
await this.s3Client.send(
new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key,
})
);
} catch (error) {
// Log error but don't throw to avoid cascading failures
console.error(`Failed to delete S3 object: ${key}`, error);
}
} }
} }
export const new_s3_storage_adapter = (): S3StorageAdapter => {
const config = S3StorageConfig.parse({
endpoint: process.env.S3_ENDPOINT,
bucket: process.env.S3_BUCKET_NAME,
region: process.env.S3_REGION,
accessKey: process.env.S3_ACCESS_KEY,
secretKey: process.env.S3_SECRET_KEY,
});
return new S3StorageAdapter(config);
};

View File

@@ -0,0 +1,38 @@
'use server';
import { createStorageProvider } from '@/lib/storage/storage.factory';
import { StorageProvider } from '@/lib/storage/storage.interface';
import { TypedResult } from '@/utils/types/results';
const storage: StorageProvider = createStorageProvider();
export const getSignedUrl = async (
key: string,
storageProvider?: StorageProvider
): Promise<TypedResult<string>> => {
if (!storageProvider) {
storageProvider = storage;
}
return await storageProvider.get(key);
};
export const getPutUrl = async (
key: string,
contentType: string,
storageProvider?: StorageProvider
): Promise<TypedResult<string>> => {
if (!storageProvider) {
storageProvider = storage;
}
return await storageProvider.put(key, contentType);
};
export const deleteByKey = async (
key: string,
storageProvider?: StorageProvider
): Promise<TypedResult<void>> => {
if (!storageProvider) {
storageProvider = storage;
}
return await storageProvider.delete(key);
};

View File

@@ -1,28 +1,13 @@
import { import { new_s3_storage_adapter } from '@/lib/storage/storage.adapter';
LocalStorageAdapter,
S3StorageAdapter,
} from '@/lib/storage/storage.adapter';
import { StorageProvider } from '@/lib/storage/storage.interface'; import { StorageProvider } from '@/lib/storage/storage.interface';
/** /**
* Factory function to create the appropriate storage provider based on environment * Factory function to create the appropriate storage provider based on environment
*/ */
export function createStorageProvider(): StorageProvider { export function createStorageProvider(): StorageProvider {
const storageType = process.env.STORAGE_TYPE || 'local'; const storage_provider = new_s3_storage_adapter();
if (!storage_provider) {
if (storageType === 's3') { throw new Error('Failed to create storage provider');
const bucketName = process.env.S3_BUCKET_NAME;
const region = process.env.S3_REGION || 'us-east-1';
if (!bucketName) {
throw new Error(
'S3_BUCKET_NAME environment variable is required when STORAGE_TYPE=s3'
);
}
return new S3StorageAdapter(bucketName, region);
} }
return storage_provider;
// Default to local storage
return new LocalStorageAdapter();
} }

View File

@@ -1,3 +1,5 @@
import { TypedResult } from '@/utils/types/results';
/** /**
* Result returned from storage operations * Result returned from storage operations
*/ */
@@ -13,22 +15,23 @@ export interface StorageResult {
*/ */
export interface StorageProvider { export interface StorageProvider {
/** /**
* Uploads a file to storage * Gets a presigned url for the requested file
* @param key - The unique key/path for the file * @param key - The unique key/path to store the file under
* @param file - The file content as Buffer or Blob
* @param contentType - MIME type of the file
* @returns Promise<StorageResult> - Result containing storage metadata and public URL
*/ */
put( get(key: string): Promise<TypedResult<string>>;
key: string,
file: Buffer | Blob, /**
contentType: string * Uploads a file to storage
): Promise<StorageResult>; * @param key - The unique key/path to store the file under
* @param contentType - The MIME type of the file being uploaded
* @returns Promise<string> - The public URL of the uploaded file
*/
put(key: string, contentType: string): Promise<TypedResult<string>>;
/** /**
* Deletes a file from storage * Deletes a file from storage
* @param key - The unique key/path of the file to delete * @param key - The unique key/path of the file to delete
* @returns Promise<void> * @returns Promise<void>
*/ */
delete(key: string): Promise<void>; delete(key: string): Promise<TypedResult<void>>;
} }

View File

@@ -0,0 +1,41 @@
'use client';
import * as storage from '@/lib/storage/storage.external';
import { StorageProvider } from '@/lib/storage/storage.interface';
import { wrap } from '@/utils/types/results';
import { z } from 'zod';
export const FileUploadResp = z.object({
signedUrl: z.string(),
key: z.string(),
});
export type FileUploadResp = z.infer<StorageProvider>;
export const uploadFile = wrap(async (file: File) => {
const uniqueFileKey = crypto.randomUUID();
const result = await storage.getPutUrl(uniqueFileKey, file.type);
if (!result.ok) {
throw new Error('File upload failed');
}
const response = await fetch(result.value, {
method: 'PUT',
headers: {
'Content-Type': file.type,
},
body: file,
});
if (!response.ok) {
throw new Error('Failed to upload file');
}
const presignedUrl = await storage.getSignedUrl(uniqueFileKey);
if (!presignedUrl.ok) {
throw new Error('Failed to retrieve file URL');
}
return {
signedUrl: presignedUrl.value,
key: uniqueFileKey,
};
});

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { saveArticle } from '@/lib/feature/article/article.external'; import { saveArticle } from '@/lib/feature/article/article.external';
import { uploadFile } from '@/lib/storage/storage.utils';
import { FileUploadField } from '@/ui/components/internal/file-upload-field'; import { FileUploadField } from '@/ui/components/internal/file-upload-field';
import { Button } from '@/ui/components/shadcn/button'; import { Button } from '@/ui/components/shadcn/button';
import { import {
@@ -60,7 +61,7 @@ export const CreateArticleForm = () => {
title: z.string().min(3).max(255), title: z.string().min(3).max(255),
slug: z.string().min(3), slug: z.string().min(3),
description: z.string().min(10), description: z.string().min(10),
coverImageUrl: z.string().url('Cover image URL must be a valid URL'), coverImageUrl: z.url('Cover image URL must be a valid URL'),
content: z content: z
.string() .string()
.min(10, 'Article content must have at least 10 characters'), .min(10, 'Article content must have at least 10 characters'),
@@ -119,19 +120,27 @@ export const CreateArticleForm = () => {
); );
const handleCoverImageFileChange = useCallback( const handleCoverImageFileChange = useCallback(
(file: File | null) => { async (file: File | null) => {
if (coverImageUrlRef.current) { if (coverImageUrlRef.current) {
URL.revokeObjectURL(coverImageUrlRef.current); URL.revokeObjectURL(coverImageUrlRef.current);
coverImageUrlRef.current = null; coverImageUrlRef.current = null;
} }
setCoverImageFile(file); setCoverImageFile(file);
if (file) { if (!file) {
const url = URL.createObjectURL(file); setCoverImageFile(null);
coverImageUrlRef.current = url;
form.setValue('coverImageUrl', url);
} else {
form.setValue('coverImageUrl', ''); form.setValue('coverImageUrl', '');
return;
} }
const fileMetadataResult = await uploadFile(file);
if (!fileMetadataResult.ok) {
setCoverImageFile(null);
form.setValue('coverImageUrl', '');
toast((fileMetadataResult.error as Error).message);
return;
}
const fileMetadata = fileMetadataResult.value;
coverImageUrlRef.current = fileMetadata.signedUrl;
form.setValue('coverImageUrl', fileMetadata.signedUrl);
}, },
[form] [form]
); );

View File

@@ -21,7 +21,7 @@ import React, { useCallback } from 'react';
export interface FileUploadFieldProps { export interface FileUploadFieldProps {
file: File | null; file: File | null;
onFileChange: (file: File | null) => void; onFileChange: (file: File | null) => Promise<void>;
accept?: string; accept?: string;
validate?: (file: File) => string | null; validate?: (file: File) => string | null;
onFileReject?: (file: File, message: string) => void; onFileReject?: (file: File, message: string) => void;
@@ -45,7 +45,8 @@ export const FileUploadField: React.FC<FileUploadFieldProps> = ({
const handleAccept = useCallback( const handleAccept = useCallback(
(files: File[]) => { (files: File[]) => {
const accepted = files[0]; const accepted = files[0];
if (accepted) onFileChange(accepted); if (!accepted) return;
onFileChange(accepted).then(() => {});
}, },
[onFileChange] [onFileChange]
); );

View File

@@ -0,0 +1,37 @@
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
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<
F extends (...args: never[]) => Promise<unknown>,
E = unknown,
>(
fn: F,
mapError: (e: unknown) => E = (e) => e as E
): (...args: Parameters<F>) => Promise<Result<Awaited<ReturnType<F>>, E>> {
return async (...args) => {
try {
return {
ok: true,
value: (await fn(...args)) as Awaited<ReturnType<F>>,
};
} catch (e) {
return { ok: false, error: mapError(e) };
}
};
}

View File

@@ -1,146 +0,0 @@
import { LocalStorageAdapter } from '@/lib/storage/storage.adapter';
import { promises as fs } from 'fs';
import path from 'path';
describe('LocalStorageAdapter', () => {
const testUploadDir = 'tests/tmp/uploads';
let adapter: LocalStorageAdapter;
beforeEach(async () => {
adapter = new LocalStorageAdapter(testUploadDir);
// Clean up test directory before each test
try {
await fs.rm(testUploadDir, { recursive: true, force: true });
} catch {
// Directory doesn't exist yet
}
});
afterEach(async () => {
// Clean up test directory after each test
try {
await fs.rm(testUploadDir, { recursive: true, force: true });
} catch {
// Directory might not exist
}
});
describe('put', () => {
it('should create uploads directory if it does not exist', async () => {
const key = 'test-image.jpg';
const file = Buffer.from('test file content');
await adapter.put(key, file, 'image/jpeg');
const dirExists = await fs
.stat(testUploadDir)
.then(() => true)
.catch(() => false);
expect(dirExists).toBe(true);
});
it('should save file to disk with provided key', async () => {
const key = 'test-image.jpg';
const fileContent = 'test file content';
const file = Buffer.from(fileContent);
await adapter.put(key, file, 'image/jpeg');
const savedContent = await fs.readFile(
path.join(testUploadDir, key),
'utf-8'
);
expect(savedContent).toBe(fileContent);
});
it('should return StorageResult with local type', async () => {
const key = 'test-image.jpg';
const file = Buffer.from('test file content');
const result = await adapter.put(key, file, 'image/jpeg');
expect(result).toEqual({
type: 'local',
provider: null,
key,
publicUrl: `/tests/tmp/uploads/test-image.jpg`,
});
});
it('should handle Blob input', async () => {
const key = 'test-blob.jpg';
const fileContent = 'blob file content';
const blob = new Blob([fileContent], { type: 'image/jpeg' });
const result = await adapter.put(key, blob, 'image/jpeg');
const savedContent = await fs.readFile(
path.join(testUploadDir, key),
'utf-8'
);
expect(savedContent).toBe(fileContent);
expect(result.type).toBe('local');
});
it('should create nested directories for keys with paths', async () => {
const key = 'articles/2026/04/image.jpg';
const file = Buffer.from('nested file content');
const result = await adapter.put(key, file, 'image/jpeg');
const savedContent = await fs.readFile(
path.join(testUploadDir, key),
'utf-8'
);
expect(savedContent).toBe('nested file content');
expect(result.publicUrl).toBe(
`/tests/tmp/uploads/articles/2026/04/image.jpg`
);
});
});
describe('delete', () => {
it('should delete file from disk', async () => {
const key = 'test-image.jpg';
const file = Buffer.from('test file content');
await adapter.put(key, file, 'image/jpeg');
const filePathBeforeDelete = path.join(testUploadDir, key);
expect(
await fs
.stat(filePathBeforeDelete)
.then(() => true)
.catch(() => false)
).toBe(true);
await adapter.delete(key);
const fileExists = await fs
.stat(filePathBeforeDelete)
.then(() => true)
.catch(() => false);
expect(fileExists).toBe(false);
});
it('should not throw error if file does not exist', async () => {
const key = 'non-existent-file.jpg';
// Should not throw
await expect(adapter.delete(key)).resolves.toBeUndefined();
});
it('should delete nested files', async () => {
const key = 'articles/2026/04/image.jpg';
const file = Buffer.from('nested file content');
await adapter.put(key, file, 'image/jpeg');
await adapter.delete(key);
const fileExists = await fs
.stat(path.join(testUploadDir, key))
.then(() => true)
.catch(() => false);
expect(fileExists).toBe(false);
});
});
});