refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,188 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createChannelID, createMemeID, createMessageID} from '@fluxer/api/src/BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
import {Validator} from '@fluxer/api/src/Validator';
import {ChannelIdMessageIdParam, MemeIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
CreateFavoriteMemeBodySchema,
CreateFavoriteMemeFromUrlBodySchema,
FavoriteMemeListResponse,
FavoriteMemeResponse,
UpdateFavoriteMemeBodySchema,
} from '@fluxer/schema/src/domains/meme/MemeSchemas';
export function FavoriteMemeController(app: HonoApp) {
app.get(
'/users/@me/memes',
RateLimitMiddleware(RateLimitConfigs.FAVORITE_MEME_LIST),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'list_favorite_memes',
summary: 'List favorite memes',
responseSchema: FavoriteMemeListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Saved Media'],
description: 'Retrieves all memes saved as favorites by the authenticated user.',
}),
async (ctx) => {
const memes = await ctx.get('favoriteMemeRequestService').listFavoriteMemes({
userId: ctx.get('user').id,
});
return ctx.json(memes);
},
);
app.post(
'/users/@me/memes',
RateLimitMiddleware(RateLimitConfigs.FAVORITE_MEME_CREATE_FROM_URL),
LoginRequired,
DefaultUserOnly,
Validator('json', CreateFavoriteMemeFromUrlBodySchema),
OpenAPI({
operationId: 'create_meme_from_url',
summary: 'Create meme from URL',
responseSchema: FavoriteMemeResponse,
statusCode: 201,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Saved Media'],
description: 'Saves a new meme to favorites from a provided URL.',
}),
async (ctx) => {
const meme = await ctx.get('favoriteMemeRequestService').createFromUrl({
user: ctx.get('user'),
data: ctx.req.valid('json'),
});
return ctx.json(meme, 201);
},
);
app.post(
'/channels/:channel_id/messages/:message_id/memes',
RateLimitMiddleware(RateLimitConfigs.FAVORITE_MEME_CREATE_FROM_MESSAGE),
LoginRequired,
DefaultUserOnly,
Validator('param', ChannelIdMessageIdParam),
Validator('json', CreateFavoriteMemeBodySchema),
OpenAPI({
operationId: 'create_meme_from_message',
summary: 'Create meme from message',
responseSchema: FavoriteMemeResponse,
statusCode: 201,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Saved Media'],
description: 'Saves a message attachment as a favorite meme.',
}),
async (ctx) => {
const user = ctx.get('user');
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const messageId = createMessageID(ctx.req.valid('param').message_id);
const meme = await ctx.get('favoriteMemeRequestService').createFromMessage({
user,
channelId,
messageId,
data: ctx.req.valid('json'),
});
return ctx.json(meme, 201);
},
);
app.get(
'/users/@me/memes/:meme_id',
RateLimitMiddleware(RateLimitConfigs.FAVORITE_MEME_GET),
LoginRequired,
DefaultUserOnly,
Validator('param', MemeIdParam),
OpenAPI({
operationId: 'get_favorite_meme',
summary: 'Get favorite meme',
responseSchema: FavoriteMemeResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Saved Media'],
description: 'Retrieves a specific favorite meme by ID.',
}),
async (ctx) => {
const user = ctx.get('user');
const memeId = createMemeID(ctx.req.valid('param').meme_id);
const meme = await ctx.get('favoriteMemeRequestService').getFavoriteMeme({
userId: user.id,
memeId,
});
return ctx.json(meme);
},
);
app.patch(
'/users/@me/memes/:meme_id',
RateLimitMiddleware(RateLimitConfigs.FAVORITE_MEME_UPDATE),
LoginRequired,
DefaultUserOnly,
Validator('param', MemeIdParam),
Validator('json', UpdateFavoriteMemeBodySchema),
OpenAPI({
operationId: 'update_favorite_meme',
summary: 'Update favorite meme',
responseSchema: FavoriteMemeResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Saved Media'],
description: 'Updates details of a favorite meme.',
}),
async (ctx) => {
const meme = await ctx.get('favoriteMemeRequestService').updateFavoriteMeme({
user: ctx.get('user'),
memeId: createMemeID(ctx.req.valid('param').meme_id),
data: ctx.req.valid('json'),
});
return ctx.json(meme);
},
);
app.delete(
'/users/@me/memes/:meme_id',
RateLimitMiddleware(RateLimitConfigs.FAVORITE_MEME_DELETE),
LoginRequired,
DefaultUserOnly,
Validator('param', MemeIdParam),
OpenAPI({
operationId: 'delete_favorite_meme',
summary: 'Delete favorite meme',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Saved Media'],
description: "Removes a favorite meme from the authenticated user's collection.",
}),
async (ctx) => {
await ctx.get('favoriteMemeRequestService').deleteFavoriteMeme({
userId: ctx.get('user').id,
memeId: createMemeID(ctx.req.valid('param').meme_id),
});
return ctx.body(null, 204);
},
);
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {userIdToChannelId} from '@fluxer/api/src/BrandedTypes';
import {makeAttachmentCdnUrl} from '@fluxer/api/src/channel/services/message/MessageHelpers';
import type {FavoriteMeme} from '@fluxer/api/src/models/FavoriteMeme';
import type {FavoriteMemeResponse} from '@fluxer/schema/src/domains/meme/MemeSchemas';
export function mapFavoriteMemeToResponse(meme: FavoriteMeme): FavoriteMemeResponse {
const url = makeAttachmentCdnUrl(userIdToChannelId(meme.userId), meme.attachmentId, meme.filename);
return {
id: meme.id.toString(),
user_id: meme.userId.toString(),
name: meme.name,
alt_text: meme.altText ?? null,
tags: meme.tags || [],
attachment_id: meme.attachmentId.toString(),
filename: meme.filename,
content_type: meme.contentType,
content_hash: meme.contentHash ?? null,
size: Number(meme.size),
width: meme.width ?? null,
height: meme.height ?? null,
duration: meme.duration ?? null,
url,
is_gifv: meme.isGifv ?? false,
klipy_slug: meme.klipySlug ?? null,
tenor_slug_id: meme.tenorSlugId ?? null,
};
}

View File

@@ -0,0 +1,135 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MemeID, UserID} from '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {FavoriteMemeRow} from '@fluxer/api/src/database/types/UserTypes';
import {
type CreateFavoriteMemeParams,
IFavoriteMemeRepository,
} from '@fluxer/api/src/favorite_meme/IFavoriteMemeRepository';
import {FavoriteMeme} from '@fluxer/api/src/models/FavoriteMeme';
import {FavoriteMemes, FavoriteMemesByMemeId} from '@fluxer/api/src/Tables';
const FETCH_FAVORITE_MEME_CQL = FavoriteMemes.selectCql({
where: [FavoriteMemes.where.eq('user_id'), FavoriteMemes.where.eq('meme_id')],
limit: 1,
});
const FETCH_FAVORITE_MEMES_BY_USER_CQL = FavoriteMemes.selectCql({
where: FavoriteMemes.where.eq('user_id'),
});
const COUNT_FAVORITE_MEMES_CQL = FavoriteMemes.selectCountCql({
where: FavoriteMemes.where.eq('user_id'),
});
export class FavoriteMemeRepository extends IFavoriteMemeRepository {
async findById(userId: UserID, memeId: MemeID): Promise<FavoriteMeme | null> {
const meme = await fetchOne<FavoriteMemeRow>(FETCH_FAVORITE_MEME_CQL, {
user_id: userId,
meme_id: memeId,
});
return meme ? new FavoriteMeme(meme) : null;
}
async findByUserId(userId: UserID): Promise<Array<FavoriteMeme>> {
const memes = await fetchMany<FavoriteMemeRow>(FETCH_FAVORITE_MEMES_BY_USER_CQL, {user_id: userId});
return memes.map((meme) => new FavoriteMeme(meme));
}
async count(userId: UserID): Promise<number> {
const result = await fetchOne<{count: bigint}>(COUNT_FAVORITE_MEMES_CQL, {user_id: userId});
return result ? Number(result.count) : 0;
}
async create(data: CreateFavoriteMemeParams): Promise<FavoriteMeme> {
const memeRow: FavoriteMemeRow = {
user_id: data.user_id,
meme_id: data.meme_id,
name: data.name,
alt_text: data.alt_text ?? null,
tags: data.tags ?? [],
attachment_id: data.attachment_id,
filename: data.filename,
content_type: data.content_type,
content_hash: data.content_hash ?? null,
size: data.size,
width: data.width ?? null,
height: data.height ?? null,
duration: data.duration ?? null,
is_gifv: data.is_gifv ?? false,
klipy_slug: data.klipy_slug ?? null,
tenor_id_str: data.tenor_slug_id ?? null,
version: 1,
};
const batch = new BatchBuilder();
batch.addPrepared(FavoriteMemes.upsertAll(memeRow));
batch.addPrepared(
FavoriteMemesByMemeId.upsertAll({
meme_id: memeRow.meme_id,
user_id: memeRow.user_id,
}),
);
await batch.execute();
return new FavoriteMeme(memeRow);
}
async update(userId: UserID, memeId: MemeID, data: CreateFavoriteMemeParams): Promise<FavoriteMeme> {
const memeRow: FavoriteMemeRow = {
user_id: userId,
meme_id: memeId,
name: data.name,
alt_text: data.alt_text ?? null,
tags: data.tags ?? [],
attachment_id: data.attachment_id,
filename: data.filename,
content_type: data.content_type,
content_hash: data.content_hash ?? null,
size: data.size,
width: data.width ?? null,
height: data.height ?? null,
duration: data.duration ?? null,
is_gifv: data.is_gifv ?? false,
klipy_slug: data.klipy_slug ?? null,
tenor_id_str: data.tenor_slug_id ?? null,
version: 1,
};
await upsertOne(FavoriteMemes.upsertAll(memeRow));
return new FavoriteMeme(memeRow);
}
async delete(userId: UserID, memeId: MemeID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(FavoriteMemes.deleteByPk({user_id: userId, meme_id: memeId}));
batch.addPrepared(FavoriteMemesByMemeId.deleteByPk({meme_id: memeId, user_id: userId}));
await batch.execute();
}
async deleteAllByUserId(userId: UserID): Promise<void> {
const memes = await this.findByUserId(userId);
for (const meme of memes) {
await this.delete(userId, meme.id);
}
}
}

View File

@@ -0,0 +1,125 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MemeID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
import {mapFavoriteMemeToResponse} from '@fluxer/api/src/favorite_meme/FavoriteMemeModel';
import type {FavoriteMemeService} from '@fluxer/api/src/favorite_meme/FavoriteMemeService';
import type {User} from '@fluxer/api/src/models/User';
import {UnknownFavoriteMemeError} from '@fluxer/errors/src/domains/core/UnknownFavoriteMemeError';
import type {
CreateFavoriteMemeBodySchema,
CreateFavoriteMemeFromUrlBodySchema,
FavoriteMemeListResponse,
FavoriteMemeResponse,
UpdateFavoriteMemeBodySchema,
} from '@fluxer/schema/src/domains/meme/MemeSchemas';
interface FavoriteMemeListParams {
userId: UserID;
}
interface FavoriteMemeCreateFromUrlParams {
user: User;
data: CreateFavoriteMemeFromUrlBodySchema;
}
interface FavoriteMemeCreateFromMessageParams {
user: User;
channelId: ChannelID;
messageId: MessageID;
data: CreateFavoriteMemeBodySchema;
}
interface FavoriteMemeGetParams {
userId: UserID;
memeId: MemeID;
}
interface FavoriteMemeUpdateParams {
user: User;
memeId: MemeID;
data: UpdateFavoriteMemeBodySchema;
}
interface FavoriteMemeDeleteParams {
userId: UserID;
memeId: MemeID;
}
export class FavoriteMemeRequestService {
constructor(private readonly favoriteMemeService: FavoriteMemeService) {}
async listFavoriteMemes(params: FavoriteMemeListParams): Promise<FavoriteMemeListResponse> {
const memes = await this.favoriteMemeService.listFavoriteMemes(params.userId);
return memes.map((meme) => mapFavoriteMemeToResponse(meme));
}
async createFromUrl(params: FavoriteMemeCreateFromUrlParams): Promise<FavoriteMemeResponse> {
const {user, data} = params;
const meme = await this.favoriteMemeService.createFromUrl({
user,
url: data.url,
name: data.name,
altText: data.alt_text ?? undefined,
tags: data.tags ?? undefined,
klipySlug: data.klipy_slug ?? undefined,
tenorSlugId: data.tenor_slug_id ?? undefined,
});
return mapFavoriteMemeToResponse(meme);
}
async createFromMessage(params: FavoriteMemeCreateFromMessageParams): Promise<FavoriteMemeResponse> {
const {user, channelId, messageId, data} = params;
const meme = await this.favoriteMemeService.createFromMessage({
user,
channelId,
messageId,
attachmentId: data.attachment_id?.toString(),
embedIndex: data.embed_index ?? undefined,
name: data.name,
altText: data.alt_text ?? undefined,
tags: data.tags ?? undefined,
});
return mapFavoriteMemeToResponse(meme);
}
async getFavoriteMeme(params: FavoriteMemeGetParams): Promise<FavoriteMemeResponse> {
const meme = await this.favoriteMemeService.getFavoriteMeme(params.userId, params.memeId);
if (!meme) {
throw new UnknownFavoriteMemeError();
}
return mapFavoriteMemeToResponse(meme);
}
async updateFavoriteMeme(params: FavoriteMemeUpdateParams): Promise<FavoriteMemeResponse> {
const {user, memeId, data} = params;
const meme = await this.favoriteMemeService.update({
user,
memeId,
name: data.name ?? undefined,
altText: data.alt_text === undefined ? undefined : data.alt_text,
tags: data.tags ?? undefined,
});
return mapFavoriteMemeToResponse(meme);
}
async deleteFavoriteMeme(params: FavoriteMemeDeleteParams): Promise<void> {
await this.favoriteMemeService.delete(params.userId, params.memeId);
}
}

View File

@@ -0,0 +1,705 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MemeID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
import {createAttachmentID, createMemeID, userIdToChannelId} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '@fluxer/api/src/channel/services/message/MessageHelpers';
import {mapFavoriteMemeToResponse} from '@fluxer/api/src/favorite_meme/FavoriteMemeModel';
import type {IFavoriteMemeRepository} from '@fluxer/api/src/favorite_meme/IFavoriteMemeRepository';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import type {IUnfurlerService} from '@fluxer/api/src/infrastructure/IUnfurlerService';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import {Logger} from '@fluxer/api/src/Logger';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {FavoriteMeme} from '@fluxer/api/src/models/FavoriteMeme';
import type {Message} from '@fluxer/api/src/models/Message';
import type {User} from '@fluxer/api/src/models/User';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {MAX_FAVORITE_MEME_TAGS, MAX_FAVORITE_MEMES_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MaxFavoriteMemesError} from '@fluxer/errors/src/domains/core/MaxFavoriteMemesError';
import {MediaMetadataError} from '@fluxer/errors/src/domains/core/MediaMetadataError';
import {UnknownFavoriteMemeError} from '@fluxer/errors/src/domains/core/UnknownFavoriteMemeError';
import mime from 'mime';
export class FavoriteMemeService {
constructor(
private readonly favoriteMemeRepository: IFavoriteMemeRepository,
private readonly channelService: ChannelService,
private readonly storageService: IStorageService,
private readonly mediaService: IMediaService,
private readonly snowflakeService: SnowflakeService,
private readonly gatewayService: IGatewayService,
private readonly unfurlerService: IUnfurlerService,
private readonly limitConfigService: LimitConfigService,
) {}
async createFromMessage({
user,
channelId,
messageId,
attachmentId,
embedIndex,
name,
altText,
tags,
}: {
user: User;
channelId: ChannelID;
messageId: MessageID;
attachmentId?: string;
embedIndex?: number;
name: string;
altText?: string;
tags?: Array<string>;
}): Promise<FavoriteMeme> {
const count = await this.favoriteMemeRepository.count(user.id);
const fallbackLimit = MAX_FAVORITE_MEMES_NON_PREMIUM;
const maxMemes = this.resolveUserLimit(user, 'max_favorite_memes', fallbackLimit);
if (count >= maxMemes) {
throw new MaxFavoriteMemesError(maxMemes);
}
await this.channelService.getChannelAuthenticated({userId: user.id, channelId});
const message = await this.channelService.getMessage({userId: user.id, channelId, messageId});
if (!message) {
throw new UnknownMessageError();
}
const media = this.findMediaInMessage(message, attachmentId, embedIndex);
if (!media) {
throw InputValidationError.fromCode('media', ValidationErrorCodes.NO_VALID_MEDIA_IN_MESSAGE);
}
const todoTags = tags ?? [];
this.ensureFavoriteMemeTagLimit(user, todoTags);
const existingMemes = await this.favoriteMemeRepository.findByUserId(user.id);
if (media.contentHash) {
const duplicate = existingMemes.find((meme) => meme.contentHash === media.contentHash);
Logger.debug(
{
userId: user.id.toString(),
contentHash: media.contentHash,
source: 'pre-metadata',
duplicate: Boolean(duplicate),
channelId: channelId.toString(),
messageId: messageId.toString(),
},
'Favorite meme duplicate check (pre-metadata)',
);
if (duplicate) {
throw InputValidationError.fromCode('media', ValidationErrorCodes.MEDIA_ALREADY_IN_FAVORITE_MEMES);
}
}
const metadata = await this.mediaService.getMetadata(
media.isExternal
? {
type: 'external',
url: media.url,
with_base64: true,
isNSFWAllowed: true,
}
: {
type: 's3',
bucket: Config.s3.buckets.cdn,
key: media.sourceKey,
with_base64: true,
isNSFWAllowed: true,
},
);
if (!metadata) {
throw new MediaMetadataError(media.isExternal ? 'external URL' : 'CDN');
}
const contentHash = media.contentHash ?? metadata.content_hash;
Logger.debug(
{
userId: user.id.toString(),
contentHash,
url: media.url,
source: 'post-metadata',
duplicate: existingMemes.some((meme) => meme.contentHash === contentHash),
channelId: channelId.toString(),
messageId: messageId.toString(),
},
'Favorite meme duplicate check (post-metadata)',
);
const fileData = Buffer.from(metadata.base64 ?? '', 'base64');
const updatedMetadata = media.isExternal
? {
contentType: metadata.content_type,
size: metadata.size,
width: metadata.width,
height: metadata.height,
duration: metadata.duration && metadata.duration > 0 ? metadata.duration : null,
}
: null;
const duplicate = existingMemes.find((meme) => meme.contentHash === contentHash);
if (duplicate) {
throw InputValidationError.fromCode('media', ValidationErrorCodes.MEDIA_ALREADY_IN_FAVORITE_MEMES);
}
const memeId = createMemeID(await this.snowflakeService.generate());
const userChannelId = userIdToChannelId(user.id);
const newAttachmentId = createAttachmentID(await this.snowflakeService.generate());
const storageKey = makeAttachmentCdnKey(userChannelId, newAttachmentId, media.filename);
await this.storageService.uploadObject({
bucket: Config.s3.buckets.cdn,
key: storageKey,
body: fileData,
contentType: media.contentType,
});
const favoriteMeme = await this.favoriteMemeRepository.create({
user_id: user.id,
meme_id: memeId,
name: name.trim(),
alt_text: altText?.trim() || media.altText || null,
tags: todoTags,
attachment_id: newAttachmentId,
filename: media.filename,
content_type: updatedMetadata?.contentType ?? media.contentType,
content_hash: contentHash,
size: updatedMetadata ? BigInt(updatedMetadata.size) : media.size,
width: updatedMetadata?.width ?? media.width,
height: updatedMetadata?.height ?? media.height,
duration: updatedMetadata?.duration ?? media.duration,
is_gifv: media.isGifv,
klipy_slug: media.klipySlug,
});
const responseData = mapFavoriteMemeToResponse(favoriteMeme);
await this.gatewayService.dispatchPresence({
userId: user.id,
event: 'FAVORITE_MEME_CREATE',
data: responseData,
});
Logger.debug({userId: user.id, memeId}, 'Created favorite meme');
return favoriteMeme;
}
async createFromUrl({
user,
url,
name,
altText,
tags,
isGifv = false,
klipySlug,
tenorSlugId,
}: {
user: User;
url: string;
name?: string | null;
altText?: string;
tags?: Array<string>;
isGifv?: boolean;
klipySlug?: string;
tenorSlugId?: string;
}): Promise<FavoriteMeme> {
const count = await this.favoriteMemeRepository.count(user.id);
const fallbackLimit = MAX_FAVORITE_MEMES_NON_PREMIUM;
const maxMemes = this.resolveUserLimit(user, 'max_favorite_memes', fallbackLimit);
if (count >= maxMemes) {
throw new MaxFavoriteMemesError(maxMemes);
}
const urlTags = tags ?? [];
this.ensureFavoriteMemeTagLimit(user, urlTags);
const metadata = await this.mediaService.getMetadata({
type: 'external',
url,
with_base64: true,
isNSFWAllowed: true,
});
if (!metadata) {
throw new MediaMetadataError('URL');
}
let contentHash = metadata.content_hash;
const fileData = Buffer.from(metadata.base64 ?? '', 'base64');
const normalizedKlipySlug = this.normalizeKlipySlug(klipySlug) ?? this.extractKlipySlugFromUrl(url) ?? undefined;
const normalizedTenorSlugId =
this.normalizeTenorSlugId(tenorSlugId) ?? this.extractTenorSlugIdFromUrl(url) ?? undefined;
if (normalizedKlipySlug) {
try {
const klipyUrl = this.buildKlipyGifUrl(normalizedKlipySlug);
const unfurled = await this.unfurlerService.unfurl(klipyUrl, true);
if (unfurled.length > 0 && unfurled[0].video?.content_hash) {
contentHash = unfurled[0].video.content_hash;
Logger.debug(
{klipySlug: normalizedKlipySlug, contentHash},
'Using unfurled video content_hash for KLIPY GIF',
);
}
} catch (error) {
Logger.warn({error, klipySlug: normalizedKlipySlug}, 'Failed to unfurl KLIPY URL, using original content_hash');
}
} else if (normalizedTenorSlugId) {
try {
const tenorUrl = this.buildTenorGifUrl(normalizedTenorSlugId);
const unfurled = await this.unfurlerService.unfurl(tenorUrl, true);
if (unfurled.length > 0 && unfurled[0].video?.content_hash) {
contentHash = unfurled[0].video.content_hash;
Logger.debug(
{tenorSlugId: normalizedTenorSlugId, contentHash},
'Using unfurled video content_hash for Tenor GIF',
);
}
} catch (error) {
Logger.warn(
{error, tenorSlugId: normalizedTenorSlugId},
'Failed to unfurl Tenor URL, using original content_hash',
);
}
}
const existingMemes = await this.favoriteMemeRepository.findByUserId(user.id);
const duplicate = existingMemes.find((meme) => meme.contentHash === contentHash);
if (duplicate) {
throw InputValidationError.fromCode('media', ValidationErrorCodes.MEDIA_ALREADY_IN_FAVORITE_MEMES);
}
const filename = this.buildFilenameFromUrl(url, metadata.content_type);
const finalName = this.resolveFavoriteMemeName(name, filename);
const memeId = createMemeID(await this.snowflakeService.generate());
const userChannelId = userIdToChannelId(user.id);
const newAttachmentId = createAttachmentID(await this.snowflakeService.generate());
const storageKey = makeAttachmentCdnKey(userChannelId, newAttachmentId, filename);
await this.storageService.uploadObject({
bucket: Config.s3.buckets.cdn,
key: storageKey,
body: fileData,
contentType: metadata.content_type,
});
const favoriteMeme = await this.favoriteMemeRepository.create({
user_id: user.id,
meme_id: memeId,
name: finalName,
alt_text: altText?.trim() || null,
tags: urlTags,
attachment_id: newAttachmentId,
filename,
content_type: metadata.content_type,
content_hash: contentHash,
size: BigInt(metadata.size),
width: metadata.width || null,
height: metadata.height || null,
duration: metadata.duration && metadata.duration > 0 ? metadata.duration : null,
is_gifv: isGifv,
klipy_slug: normalizedKlipySlug ?? null,
tenor_slug_id: normalizedTenorSlugId ?? null,
});
const responseData = mapFavoriteMemeToResponse(favoriteMeme);
await this.gatewayService.dispatchPresence({
userId: user.id,
event: 'FAVORITE_MEME_CREATE',
data: responseData,
});
Logger.debug({userId: user.id, memeId, url}, 'Created favorite meme from URL');
return favoriteMeme;
}
async update({
user,
memeId,
name,
altText,
tags,
}: {
user: User;
memeId: MemeID;
name?: string;
altText?: string | null;
tags?: Array<string>;
}): Promise<FavoriteMeme> {
const existingMeme = await this.favoriteMemeRepository.findById(user.id, memeId);
if (!existingMeme) {
throw new UnknownFavoriteMemeError();
}
const updatedTags = tags ?? existingMeme.tags;
this.ensureFavoriteMemeTagLimit(user, updatedTags);
const updatedRow = {
user_id: user.id,
meme_id: memeId,
name: name ?? existingMeme.name,
alt_text: altText !== undefined ? altText : existingMeme.altText,
tags: updatedTags,
attachment_id: existingMeme.attachmentId,
filename: existingMeme.filename,
content_type: existingMeme.contentType,
content_hash: existingMeme.contentHash,
size: existingMeme.size,
width: existingMeme.width,
height: existingMeme.height,
duration: existingMeme.duration,
is_gifv: existingMeme.isGifv,
klipy_slug: existingMeme.klipySlug,
tenor_slug_id: existingMeme.tenorSlugId,
version: existingMeme.version,
};
const updatedMeme = await this.favoriteMemeRepository.update(user.id, memeId, updatedRow);
const responseData = mapFavoriteMemeToResponse(updatedMeme);
await this.gatewayService.dispatchPresence({
userId: user.id,
event: 'FAVORITE_MEME_UPDATE',
data: responseData,
});
Logger.debug({userId: user.id, memeId}, 'Updated favorite meme');
return updatedMeme;
}
async delete(userId: UserID, memeId: MemeID): Promise<void> {
const meme = await this.favoriteMemeRepository.findById(userId, memeId);
if (!meme) {
return;
}
try {
await this.storageService.deleteObject(Config.s3.buckets.cdn, meme.storageKey);
} catch (error) {
Logger.error({error, userId, memeId}, 'Failed to delete meme from storage');
}
await this.favoriteMemeRepository.delete(userId, memeId);
await this.gatewayService.dispatchPresence({
userId,
event: 'FAVORITE_MEME_DELETE',
data: {meme_id: memeId.toString()},
});
Logger.debug({userId, memeId}, 'Deleted favorite meme');
}
async getFavoriteMeme(userId: UserID, memeId: MemeID): Promise<FavoriteMeme | null> {
return this.favoriteMemeRepository.findById(userId, memeId);
}
async listFavoriteMemes(userId: UserID): Promise<Array<FavoriteMeme>> {
return this.favoriteMemeRepository.findByUserId(userId);
}
private buildFilenameFromUrl(url: string, contentType: string): string {
const extension = mime.getExtension(contentType) || 'bin';
try {
const urlPath = new URL(url).pathname;
const urlFilename = urlPath.split('/').pop() || 'media';
return urlFilename.includes('.') ? urlFilename : `${urlFilename}.${extension}`;
} catch {
return `media.${extension}`;
}
}
private normalizeKlipySlug(klipySlug?: string): string | undefined {
if (!klipySlug) {
return undefined;
}
const trimmed = klipySlug.trim();
if (!trimmed) {
return undefined;
}
return this.extractKlipySlugFromUrl(trimmed) ?? trimmed;
}
private extractKlipySlugFromUrl(url: string): string | null {
try {
const parsedUrl = new URL(url);
const hostname = parsedUrl.hostname.toLowerCase();
if (hostname !== 'klipy.com' && hostname !== 'www.klipy.com') {
return null;
}
const pathMatch = parsedUrl.pathname.match(/^\/(gif|gifs|clip|clips)\/([^/]+)/i);
if (!pathMatch?.[2]) {
return null;
}
const slug = decodeURIComponent(pathMatch[2]).trim();
return slug.length > 0 ? slug : null;
} catch {
return null;
}
}
private buildKlipyGifUrl(klipySlug: string): string {
return `https://klipy.com/gifs/${encodeURIComponent(klipySlug)}`;
}
private normalizeTenorSlugId(tenorSlugId?: string): string | undefined {
if (!tenorSlugId) {
return undefined;
}
const trimmed = tenorSlugId.trim();
if (!trimmed) {
return undefined;
}
const extracted = this.extractTenorSlugIdFromUrl(trimmed);
if (extracted) {
return extracted;
}
const withoutLeadingSlash = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed;
if (withoutLeadingSlash.toLowerCase().startsWith('view/')) {
return withoutLeadingSlash.replace(/\/+$/, '');
}
// Allow callers to pass just the last path segment and normalise it.
if (!withoutLeadingSlash.includes('/')) {
return `view/${withoutLeadingSlash.replace(/\/+$/, '')}`;
}
return undefined;
}
private extractTenorSlugIdFromUrl(url: string): string | null {
try {
const parsedUrl = new URL(url);
const hostname = parsedUrl.hostname.toLowerCase();
if (hostname !== 'tenor.com' && hostname !== 'www.tenor.com') {
return null;
}
const pathMatch = parsedUrl.pathname.match(/^\/view\/([^/]+)/i);
if (!pathMatch?.[1]) {
return null;
}
const slugId = decodeURIComponent(pathMatch[1]).trim();
if (!slugId) {
return null;
}
return `view/${slugId}`;
} catch {
return null;
}
}
private buildTenorGifUrl(tenorSlugId: string): string {
const normalized = this.normalizeTenorSlugId(tenorSlugId) ?? tenorSlugId;
const withoutLeadingSlash = normalized.startsWith('/') ? normalized.slice(1) : normalized;
return `https://tenor.com/${withoutLeadingSlash}`;
}
private resolveFavoriteMemeName(name: string | undefined | null, fallbackFilename: string): string {
const normalizedInput = typeof name === 'string' ? name.trim() : '';
const fallbackName = fallbackFilename.trim() || 'favorite meme';
const candidate = normalizedInput.length > 0 ? normalizedInput : fallbackName;
const finalName = candidate.slice(0, 100);
if (finalName.length === 0) {
throw InputValidationError.fromCode('name', ValidationErrorCodes.FAVORITE_MEME_NAME_REQUIRED);
}
return finalName;
}
private findMediaInMessage(
message: Message,
preferredAttachmentId?: string,
preferredEmbedIndex?: number,
): {
isExternal: boolean;
url: string;
sourceKey: string;
filename: string;
contentType: string;
size: bigint;
width: number | null;
height: number | null;
duration: number | null;
altText: string | null;
isGifv: boolean;
contentHash: string | null;
klipySlug: string | null;
} | null {
if (preferredEmbedIndex !== undefined) {
if (preferredEmbedIndex < 0 || preferredEmbedIndex >= message.embeds.length) {
throw InputValidationError.fromCode('embed_index', ValidationErrorCodes.EMBED_INDEX_OUT_OF_BOUNDS, {
embedIndex: preferredEmbedIndex,
embedCount: message.embeds.length,
});
}
const embed = message.embeds[preferredEmbedIndex];
const media = embed.image || embed.video || embed.thumbnail;
if (media?.url) {
const filename = this.extractFilenameFromUrl(media.url) || `embed_${preferredEmbedIndex}`;
const contentType = media.contentType ?? mime.getType(filename) ?? 'application/octet-stream';
if (this.isValidMediaType(contentType)) {
const isExternal = !this.isInternalCDNUrl(media.url);
const isGifv = embed.type === 'gifv';
return {
isExternal,
url: media.url,
sourceKey: isExternal ? '' : this.extractStorageKeyFromUrl(media.url) || '',
filename,
contentType,
size: BigInt(0),
width: media.width ?? null,
height: media.height ?? null,
duration: null,
altText: null,
isGifv,
contentHash: media.contentHash ?? null,
klipySlug: isGifv ? this.extractKlipySlugFromUrl(media.url) : null,
};
}
}
return null;
}
if (message.attachments.length > 0) {
let attachment: (typeof message.attachments)[0] | undefined;
if (preferredAttachmentId) {
attachment = message.attachments.find((a) => a.id.toString() === preferredAttachmentId);
if (!attachment) {
throw InputValidationError.fromCode(
'preferred_attachment_id',
ValidationErrorCodes.ATTACHMENT_ID_NOT_FOUND_IN_MESSAGE,
{attachmentId: preferredAttachmentId},
);
}
} else {
attachment = message.attachments[0];
}
if (attachment && this.isValidMediaType(attachment.contentType)) {
const isGifv = attachment.contentType === 'image/gif';
return {
isExternal: false,
url: makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename),
sourceKey: makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename),
filename: attachment.filename,
contentType: attachment.contentType,
size: attachment.size,
width: attachment.width ?? null,
height: attachment.height ?? null,
duration: attachment.duration ?? null,
altText: attachment.description ?? null,
isGifv,
contentHash: attachment.contentHash ?? null,
klipySlug: null,
};
}
}
for (const embed of message.embeds) {
const media = embed.image || embed.video || embed.thumbnail;
if (media?.url) {
const filename = this.extractFilenameFromUrl(media.url) || 'media';
const contentType = media.contentType ?? mime.getType(filename) ?? 'application/octet-stream';
if (this.isValidMediaType(contentType)) {
const isExternal = !this.isInternalCDNUrl(media.url);
const isGifv = embed.type === 'gifv';
return {
isExternal,
url: media.url,
sourceKey: isExternal ? '' : this.extractStorageKeyFromUrl(media.url) || '',
filename,
contentType,
size: BigInt(0),
width: media.width ?? null,
height: media.height ?? null,
duration: null,
altText: null,
isGifv,
contentHash: media.contentHash ?? null,
klipySlug: isGifv ? this.extractKlipySlugFromUrl(media.url) : null,
};
}
}
}
return null;
}
private isInternalCDNUrl(url: string): boolean {
return url.startsWith(`${Config.endpoints.media}/`);
}
private isValidMediaType(contentType: string): boolean {
return contentType.startsWith('image/') || contentType.startsWith('video/') || contentType.startsWith('audio/');
}
private extractFilenameFromUrl(url: string): string | null {
try {
const urlObj = new URL(url);
const parts = urlObj.pathname.split('/');
return parts[parts.length - 1] || null;
} catch {
return null;
}
}
private extractStorageKeyFromUrl(url: string): string | null {
try {
const urlObj = new URL(url);
return urlObj.pathname.substring(1);
} catch {
return null;
}
}
private ensureFavoriteMemeTagLimit(user: User, tags?: Array<string>): void {
const limit = this.resolveUserLimit(user, 'max_favorite_meme_tags', MAX_FAVORITE_MEME_TAGS);
if ((tags?.length ?? 0) > limit) {
throw InputValidationError.create('tags', `Maximum ${limit} tags allowed`);
}
}
private resolveUserLimit(user: User, key: LimitKey, fallback: number): number {
const ctx = createLimitMatchContext({user});
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AttachmentID, MemeID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {FavoriteMeme} from '@fluxer/api/src/models/FavoriteMeme';
export interface CreateFavoriteMemeParams {
user_id: UserID;
meme_id: MemeID;
name: string;
alt_text?: string | null;
tags?: Array<string>;
attachment_id: AttachmentID;
filename: string;
content_type: string;
content_hash?: string | null;
size: bigint;
width?: number | null;
height?: number | null;
duration?: number | null;
is_gifv?: boolean;
klipy_slug?: string | null;
tenor_slug_id?: string | null;
}
export abstract class IFavoriteMemeRepository {
abstract create(data: CreateFavoriteMemeParams): Promise<FavoriteMeme>;
abstract findById(userId: UserID, memeId: MemeID): Promise<FavoriteMeme | null>;
abstract findByUserId(userId: UserID): Promise<Array<FavoriteMeme>>;
abstract update(userId: UserID, memeId: MemeID, data: CreateFavoriteMemeParams): Promise<FavoriteMeme>;
abstract delete(userId: UserID, memeId: MemeID): Promise<void>;
abstract deleteAllByUserId(userId: UserID): Promise<void>;
abstract count(userId: UserID): Promise<number>;
}

View File

@@ -0,0 +1,524 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createTestAccountForAttachmentTests,
setupTestGuildAndChannel,
} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {
createFavoriteMemeFromMessage,
createFavoriteMemeFromUrl,
createMessageWithImageAttachment,
deleteFavoriteMeme,
getFavoriteMeme,
listFavoriteMemes,
updateFavoriteMeme,
} from '@fluxer/api/src/user/tests/FavoriteMemeTestUtils';
import type {FavoriteMemeResponse} from '@fluxer/schema/src/domains/meme/MemeSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
const TEST_IMAGE_URL = 'https://picsum.photos/id/1/100';
describe('Favorite Meme Extended Tests', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
describe('Create from URL', () => {
test('should create meme from valid image URL', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const meme = await createFavoriteMemeFromUrl(harness, account.token, {
url: TEST_IMAGE_URL,
name: 'My Test Meme',
});
expect(meme.id).toBeTruthy();
expect(meme.name).toBe('My Test Meme');
expect(meme.user_id).toBe(account.userId);
expect(meme.url).toBeTruthy();
});
test('should create meme with all metadata from URL', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const meme = await createFavoriteMemeFromUrl(harness, account.token, {
url: TEST_IMAGE_URL,
name: 'Full Metadata Meme',
alt_text: 'A funny meme for testing purposes',
tags: ['funny', 'test', 'meme'],
});
expect(meme.name).toBe('Full Metadata Meme');
expect(meme.alt_text).toBe('A funny meme for testing purposes');
expect(meme.tags).toEqual(['funny', 'test', 'meme']);
});
test('should reject name over 100 characters', async () => {
const account = await createTestAccountForAttachmentTests(harness);
await createBuilder(harness, account.token)
.post('/users/@me/memes')
.body({
url: TEST_IMAGE_URL,
name: 'a'.repeat(101),
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject alt_text over 500 characters', async () => {
const account = await createTestAccountForAttachmentTests(harness);
await createBuilder(harness, account.token)
.post('/users/@me/memes')
.body({
url: TEST_IMAGE_URL,
name: 'Test Meme',
alt_text: 'a'.repeat(501),
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject more than 10 tags', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const tags = Array.from({length: 11}, (_, i) => `tag${i}`);
await createBuilder(harness, account.token)
.post('/users/@me/memes')
.body({
url: TEST_IMAGE_URL,
name: 'Test Meme',
tags,
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject tag over 30 characters', async () => {
const account = await createTestAccountForAttachmentTests(harness);
await createBuilder(harness, account.token)
.post('/users/@me/memes')
.body({
url: TEST_IMAGE_URL,
name: 'Test Meme',
tags: ['a'.repeat(31)],
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject invalid URL', async () => {
const account = await createTestAccountForAttachmentTests(harness);
await createBuilder(harness, account.token)
.post('/users/@me/memes')
.body({
url: 'not-a-valid-url',
name: 'Test Meme',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject missing URL', async () => {
const account = await createTestAccountForAttachmentTests(harness);
await createBuilder(harness, account.token)
.post('/users/@me/memes')
.body({name: 'Test Meme'})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should require authentication', async () => {
await createBuilderWithoutAuth(harness)
.post('/users/@me/memes')
.body({
url: TEST_IMAGE_URL,
name: 'Test Meme',
})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
test('should derive name from URL filename when not provided', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const meme = await createFavoriteMemeFromUrl(harness, account.token, {
url: TEST_IMAGE_URL,
});
expect(meme.name).toBeTruthy();
expect(meme.name.length).toBeGreaterThan(0);
});
});
describe('Personal Notes (DM to self with meme)', () => {
test('should send favorite meme as attachment in personal notes', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const meme = await createFavoriteMemeFromUrl(harness, account.token, {
url: TEST_IMAGE_URL,
name: 'Personal Notes Meme',
});
const channelId = account.userId;
const message = await createBuilder<{
id: string;
attachments: Array<{id: string; filename: string}>;
}>(harness, account.token)
.post(`/channels/${channelId}/messages`)
.body({favorite_meme_id: meme.id})
.expect(HTTP_STATUS.OK)
.execute();
expect(message.attachments.length).toBe(1);
expect(message.attachments[0].filename).toBe(meme.filename);
});
test('should fetch personal note message with meme attachment', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const meme = await createFavoriteMemeFromUrl(harness, account.token, {
url: TEST_IMAGE_URL,
name: 'Fetchable Meme',
});
const channelId = account.userId;
const sendResponse = await createBuilder<{id: string}>(harness, account.token)
.post(`/channels/${channelId}/messages`)
.body({favorite_meme_id: meme.id})
.execute();
const fetchResponse = await createBuilder<{
id: string;
attachments: Array<{id: string; filename: string}>;
}>(harness, account.token)
.get(`/channels/${channelId}/messages/${sendResponse.id}`)
.execute();
expect(fetchResponse.id).toBe(sendResponse.id);
expect(fetchResponse.attachments.length).toBe(1);
expect(fetchResponse.attachments[0].filename).toBe(meme.filename);
});
});
describe('Update Favorite Meme', () => {
test('should update only name without affecting other fields', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Original',
alt_text: 'Original description',
tags: ['original'],
});
const updated = await updateFavoriteMeme(harness, account.token, created.id, {
name: 'Updated Name',
});
expect(updated.name).toBe('Updated Name');
expect(updated.alt_text).toBe('Original description');
expect(updated.tags).toEqual(['original']);
});
test('should update only alt_text without affecting other fields', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'My Meme',
tags: ['tag1'],
});
const updated = await updateFavoriteMeme(harness, account.token, created.id, {
alt_text: 'New description',
});
expect(updated.name).toBe('My Meme');
expect(updated.alt_text).toBe('New description');
expect(updated.tags).toEqual(['tag1']);
});
test('should update only tags without affecting other fields', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Tagged Meme',
alt_text: 'Description',
tags: ['old'],
});
const updated = await updateFavoriteMeme(harness, account.token, created.id, {
tags: ['new', 'tags'],
});
expect(updated.name).toBe('Tagged Meme');
expect(updated.alt_text).toBe('Description');
expect(updated.tags).toEqual(['new', 'tags']);
});
test('should update multiple fields at once', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Initial Name',
alt_text: 'Initial description',
tags: ['initial'],
});
const updated = await updateFavoriteMeme(harness, account.token, created.id, {
name: 'New Name',
alt_text: 'New description',
tags: ['new', 'multiple'],
});
expect(updated.name).toBe('New Name');
expect(updated.alt_text).toBe('New description');
expect(updated.tags).toEqual(['new', 'multiple']);
});
test('should reject name over 100 characters on update', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Valid Name',
});
await createBuilder(harness, account.token)
.patch(`/users/@me/memes/${created.id}`)
.body({name: 'a'.repeat(101)})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject alt_text over 500 characters on update', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Valid Name',
});
await createBuilder(harness, account.token)
.patch(`/users/@me/memes/${created.id}`)
.body({alt_text: 'a'.repeat(501)})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should return 404 for unknown meme on update', async () => {
const account = await createTestAccountForAttachmentTests(harness);
await createBuilder(harness, account.token)
.patch('/users/@me/memes/999999999999999999')
.body({name: 'New Name'})
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
});
describe('Delete Favorite Meme', () => {
test('should delete meme and remove from list', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'To Be Deleted',
});
const beforeList = await listFavoriteMemes(harness, account.token);
expect(beforeList.some((m) => m.id === created.id)).toBe(true);
await deleteFavoriteMeme(harness, account.token, created.id);
const afterList = await listFavoriteMemes(harness, account.token);
expect(afterList.some((m) => m.id === created.id)).toBe(false);
});
test('should delete idempotently without error', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Delete Twice',
});
await deleteFavoriteMeme(harness, account.token, created.id);
await createBuilder(harness, account.token)
.delete(`/users/@me/memes/${created.id}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
test('should return 204 for nonexistent meme', async () => {
const account = await createTestAccountForAttachmentTests(harness);
await createBuilder(harness, account.token)
.delete('/users/@me/memes/999999999999999999')
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
test('should not delete other users memes', async () => {
const account1 = await createTestAccountForAttachmentTests(harness);
const account2 = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account1);
const message = await createMessageWithImageAttachment(harness, account1.token, channel.id);
const created = await createFavoriteMemeFromMessage(harness, account1.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Private Meme',
});
await createBuilder(harness, account2.token)
.delete(`/users/@me/memes/${created.id}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const meme = await getFavoriteMeme(harness, account1.token, created.id);
expect(meme.id).toBe(created.id);
});
});
describe('List Favorite Memes', () => {
test('should return empty list when no memes exist', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const memes = await listFavoriteMemes(harness, account.token);
expect(memes).toEqual([]);
});
test('should return all memes for user', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message1 = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message1.id, {
attachment_id: message1.attachments[0].id,
name: 'First',
});
const message2 = await createMessageWithImageAttachment(harness, account.token, channel.id, 'thisisfine.gif');
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message2.id, {
attachment_id: message2.attachments[0].id,
name: 'Second',
});
const message3 = await createMessageWithImageAttachment(harness, account.token, channel.id, 'sticker.png');
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message3.id, {
attachment_id: message3.attachments[0].id,
name: 'Third',
});
const memes = await listFavoriteMemes(harness, account.token);
expect(memes.length).toBe(3);
expect(memes.some((m) => m.name === 'First')).toBe(true);
expect(memes.some((m) => m.name === 'Second')).toBe(true);
expect(memes.some((m) => m.name === 'Third')).toBe(true);
});
test('should not return memes from other users', async () => {
const account1 = await createTestAccountForAttachmentTests(harness);
const account2 = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account1);
const message = await createMessageWithImageAttachment(harness, account1.token, channel.id);
await createFavoriteMemeFromMessage(harness, account1.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Account1 Meme',
});
const account2Memes = await listFavoriteMemes(harness, account2.token);
expect(account2Memes).toEqual([]);
});
test('should include all fields in list response', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Complete Meme',
alt_text: 'Description text',
tags: ['tag1', 'tag2'],
});
const memes = await listFavoriteMemes(harness, account.token);
const meme = memes[0];
expect(meme.id).toBeTruthy();
expect(meme.user_id).toBe(account.userId);
expect(meme.name).toBe('Complete Meme');
expect(meme.alt_text).toBe('Description text');
expect(meme.tags).toEqual(['tag1', 'tag2']);
expect(meme.attachment_id).toBeTruthy();
expect(meme.filename).toBeTruthy();
expect(meme.content_type).toBeTruthy();
expect(meme.url).toBeTruthy();
});
test('should require authentication for list', async () => {
await createBuilderWithoutAuth<Array<FavoriteMemeResponse>>(harness)
.get('/users/@me/memes')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});
});