diff --git a/src/core/domain/constants/default-media.constants.ts b/src/core/domain/constants/default-media.constants.ts new file mode 100644 index 00000000..114a292b --- /dev/null +++ b/src/core/domain/constants/default-media.constants.ts @@ -0,0 +1,33 @@ +/** + * Storage key of the shared default avatar object. + * Mirrors the `avatarUrl` default in `prisma/models/profile.prisma`. + */ +export const DEFAULT_AVATAR_KEY = "avatars/default_profile.png"; + +/** + * Storage key of the shared default banner object. + * Mirrors the `bannerUrl` default in `prisma/models/profile.prisma`. + */ +export const DEFAULT_BANNER_KEY = "banners/default_banner.jpe"; + +/** + * File name stems of the shared default assets, without directory or extension. + * + * Matching on the stem keeps the guard correct even when the stored value is a + * full CDN URL, carries a cache-busting query string, or uses a different + * extension than the one written by the Prisma schema default. + */ +const DEFAULT_MEDIA_STEMS = ["default_profile", "default_banner"] as const; + +/** + * Checks whether a stored media path points at a shared default asset. + * + * Default assets are shared by every user, so they must never be deleted from + * storage when a single user replaces their avatar or banner. + * + * @param mediaPath - The stored avatar/banner path or URL. + * @returns True when the path refers to a shared default asset. + */ +export function isDefaultMediaKey(mediaPath: string): boolean { + return DEFAULT_MEDIA_STEMS.some((stem) => mediaPath.includes(stem)); +} diff --git a/src/core/use-cases/profile/update-avatar/update-avatar.usecase.ts b/src/core/use-cases/profile/update-avatar/update-avatar.usecase.ts index 9117a5ba..b9eabe84 100644 --- a/src/core/use-cases/profile/update-avatar/update-avatar.usecase.ts +++ b/src/core/use-cases/profile/update-avatar/update-avatar.usecase.ts @@ -3,8 +3,7 @@ import type { UpdateAvatarUseCaseInput } from "./update-avatar-usecase.input"; import type { StoragePort } from "@core/ports/services/storage.port"; import type { LoggerPort } from "@core/ports/services/logger.port"; import { InvalidFileTypeError } from "@core/errors"; - -const DEFAULT_AVATAR_KEY = "avatars/default_profile.png"; +import { isDefaultMediaKey } from "@core/domain/constants/default-media.constants"; /** * Use case for updating a user's profile avatar. @@ -64,7 +63,7 @@ export class UpdateAvatarUseCase { uploadedFilePath, ); - if (oldAvatarUrl && !oldAvatarUrl.includes(DEFAULT_AVATAR_KEY)) { + if (oldAvatarUrl && !isDefaultMediaKey(oldAvatarUrl)) { try { await this.storageService.delete(oldAvatarUrl); } catch (error) { diff --git a/src/core/use-cases/profile/update-banner/update-banner.usecase.ts b/src/core/use-cases/profile/update-banner/update-banner.usecase.ts index e908adca..31fbd5f0 100644 --- a/src/core/use-cases/profile/update-banner/update-banner.usecase.ts +++ b/src/core/use-cases/profile/update-banner/update-banner.usecase.ts @@ -3,8 +3,7 @@ import type { UpdateBannerUseCaseInput } from "./update-banner-usecase.input"; import { InvalidFileTypeError } from "@core/errors"; import type { StoragePort } from "@core/ports/services/storage.port"; import type { LoggerPort } from "@core/ports/services/logger.port"; - -const DEFAULT_BANNER_KEY = "banners/default_banner.jpeg"; +import { isDefaultMediaKey } from "@core/domain/constants/default-media.constants"; /** * Use case for updating a user's profile banner. @@ -65,7 +64,7 @@ export class UpdateBannerUseCase { uploadedFilePath, ); - if (oldBannerUrl && !oldBannerUrl.includes(DEFAULT_BANNER_KEY)) { + if (oldBannerUrl && !isDefaultMediaKey(oldBannerUrl)) { try { await this.storageService.delete(oldBannerUrl); } catch (error) { diff --git a/tests/unit/core/use-cases/profile/update-avatar.usecase.test.ts b/tests/unit/core/use-cases/profile/update-avatar.usecase.test.ts index c8277fa8..21de2046 100644 --- a/tests/unit/core/use-cases/profile/update-avatar.usecase.test.ts +++ b/tests/unit/core/use-cases/profile/update-avatar.usecase.test.ts @@ -4,8 +4,7 @@ import type { IProfileRepository } from "@core/ports/repositories/profile.reposi import type { StoragePort } from "@core/ports/services/storage.port"; import type { LoggerPort } from "@core/ports/services/logger.port"; import { InvalidFileTypeError } from "@core/errors"; - -const DEFAULT_AVATAR_KEY = "avatars/default_profile.png"; +import { DEFAULT_AVATAR_KEY } from "@core/domain/constants/default-media.constants"; describe("UpdateAvatarUseCase", () => { let useCase: UpdateAvatarUseCase; diff --git a/tests/unit/core/use-cases/profile/update-banner.usecase.test.ts b/tests/unit/core/use-cases/profile/update-banner.usecase.test.ts index aa65d97c..34d4a29b 100644 --- a/tests/unit/core/use-cases/profile/update-banner.usecase.test.ts +++ b/tests/unit/core/use-cases/profile/update-banner.usecase.test.ts @@ -4,8 +4,7 @@ import type { IProfileRepository } from "@core/ports/repositories/profile.reposi import type { StoragePort } from "@core/ports/services/storage.port"; import type { LoggerPort } from "@core/ports/services/logger.port"; import { InvalidFileTypeError } from "@core/errors"; - -const DEFAULT_BANNER_KEY = "banners/default_banner.jpeg"; +import { DEFAULT_BANNER_KEY } from "@core/domain/constants/default-media.constants"; describe("UpdateBannerUseCase", () => { let useCase: UpdateBannerUseCase; @@ -93,6 +92,26 @@ describe("UpdateBannerUseCase", () => { expect(storageService.delete).not.toHaveBeenCalled(); }); + it("should not call storageService.delete for the schema default banner key", async () => { + vi.mocked(profileRepository.findBannerByUserId).mockResolvedValue( + DEFAULT_BANNER_KEY, + ); + + await useCase.execute(baseInput); + + expect(storageService.delete).not.toHaveBeenCalled(); + }); + + it("should not call storageService.delete when the default banner carries a cache-busting query", async () => { + vi.mocked(profileRepository.findBannerByUserId).mockResolvedValue( + `https://cdn.example.com/${DEFAULT_BANNER_KEY}?v=1`, + ); + + await useCase.execute(baseInput); + + expect(storageService.delete).not.toHaveBeenCalled(); + }); + it("should delete the old banner when it exists and is not the default", async () => { const oldUrl = "banners/user-1-old.jpg"; vi.mocked(profileRepository.findBannerByUserId).mockResolvedValue(