Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/core/domain/constants/default-media.constants.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
23 changes: 21 additions & 2 deletions tests/unit/core/use-cases/profile/update-banner.usecase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down