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
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
final_remediation_report.md
analysis_report.md
analysis_report.md
/plan/

plan
/plan/**
plan/development-plan.md
plan/remediation-plan.md
/plan/
94 changes: 94 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"express-fileupload": "^1.5.2",
"mongoose": "^9.0.0",
"nodemon": "^3.1.11",
"redis": "^5.11.0",
"socket.io": "^4.8.1",
"winston": "^3.19.0"
},
Expand Down
61 changes: 61 additions & 0 deletions backend/src/__tests__/cacheManager.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
const redisClient = require('../lib/redis');
const CacheManager = require('../lib/cacheManager');

describe('CacheManager', () => {
const mockKey = 'test-key';
const mockData = { foo: 'bar' };
const mockTTL = 60;

beforeEach(() => {
vi.restoreAllMocks();
});

describe('getOrFetch', () => {
it('should return cached data if present', async () => {
const spyGet = vi.spyOn(redisClient, 'get').mockResolvedValue(JSON.stringify(mockData));
const fetcher = vi.fn();

const result = await CacheManager.getOrFetch(mockKey, mockTTL, fetcher);

expect(result).toEqual(mockData);
expect(spyGet).toHaveBeenCalledWith(mockKey);
expect(fetcher).not.toHaveBeenCalled();
});

it('should call fetcher and set cache if data is missing', async () => {
vi.spyOn(redisClient, 'get').mockResolvedValue(null);
const spySet = vi.spyOn(redisClient, 'setEx').mockResolvedValue('OK');
const fetcher = vi.fn().mockResolvedValue(mockData);

const result = await CacheManager.getOrFetch(mockKey, mockTTL, fetcher);

expect(result).toEqual(mockData);
expect(fetcher).toHaveBeenCalled();
expect(spySet).toHaveBeenCalledWith(mockKey, mockTTL, JSON.stringify(mockData));
});

it('should fallback to fetcher if Redis fails', async () => {
vi.spyOn(redisClient, 'get').mockRejectedValue(new Error('Redis Down'));
const fetcher = vi.fn().mockResolvedValue(mockData);

const result = await CacheManager.getOrFetch(mockKey, mockTTL, fetcher);

expect(result).toEqual(mockData);
expect(fetcher).toHaveBeenCalled();
});
});

describe('purgePattern', () => {
it('should delete keys matching a pattern', async () => {
const mockKeys = ['key1', 'key2'];
const spyKeys = vi.spyOn(redisClient, 'keys').mockResolvedValue(mockKeys);
const spyDel = vi.spyOn(redisClient, 'del').mockResolvedValue(2);

await CacheManager.purgePattern('pattern:*');

expect(spyKeys).toHaveBeenCalledWith('pattern:*');
expect(spyDel).toHaveBeenCalledWith(mockKeys);
});
});
});
50 changes: 50 additions & 0 deletions backend/src/__tests__/stats.controller.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
const { getStats } = require('../controllers/stats.controller');
const Song = require('../models/song.model');
const User = require('../models/user.model');
const Album = require('../models/album.model');
const CacheManager = require('../lib/cacheManager');

describe('StatsController - getStats', () => {
let req, res, next;

beforeEach(() => {
req = {
query: {},
auth: { userId: 'user_123' }
};
res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis()
};
next = vi.fn();
vi.restoreAllMocks();
});

it('should use global cache key for non-user queries', async () => {
const spyGetOrFetch = vi.spyOn(CacheManager, 'getOrFetch').mockImplementation(async (key, ttl, fetcher) => {
return await fetcher();
});

// Mock DB calls
vi.spyOn(Song, 'countDocuments').mockResolvedValue(10);
vi.spyOn(User, 'countDocuments').mockResolvedValue(5);
vi.spyOn(Album, 'countDocuments').mockResolvedValue(2);
vi.spyOn(Song, 'aggregate').mockResolvedValue([{ count: 3 }]);

await getStats(req, res, next);

expect(spyGetOrFetch).toHaveBeenCalledWith('music-app:stats:global', 600, expect.any(Function));
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ totalSongs: 10 }));
});

it('should use user-specific cache key when user=true', async () => {
req.query.user = 'true';
vi.spyOn(CacheManager, 'getOrFetch').mockResolvedValue({ totalSongs: 1 });

await getStats(req, res, next);

expect(CacheManager.getOrFetch).toHaveBeenCalledWith('music-app:stats:user_123', 600, expect.any(Function));
});
});
50 changes: 28 additions & 22 deletions backend/src/controllers/admin.controller.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,10 @@
const clerk = require('@clerk/express');
const Song = require("../models/song.model");
const Album = require("../models/album.model");
const cloudinary = require("../lib/cloudinary");
const { getPublicId, uploadToCloudinary } = require("../lib/cloudinaryHelper");
const CacheManager = require("../lib/cacheManager");

const getPublicId = (url) => {
if (!url) return null;
const parts = url.split("/");
const fileName = parts.pop();
const publicId = fileName.split(".")[0];
return publicId;
};

const uploadToCloudinary = async (file) => {
try {
const result = await cloudinary.uploader.upload(file.tempFilePath, {
resource_type: "auto",
});
return result.secure_url;
} catch (err) {
console.log("Error in uploadToCloudinary:", err);
throw new Error(err.message || "Cloudinary upload failed");
}
};

const createSong = async (req, res, next) => {
try {
Expand All @@ -45,8 +28,8 @@ const createSong = async (req, res, next) => {
const audioFile = req.files.audioFile;
const imageFile = req.files.imageFile;

const audioUrl = await uploadToCloudinary(audioFile);
const imageUrl = await uploadToCloudinary(imageFile);
const audioUrl = await uploadToCloudinary(audioFile, "music-app/songs");
const imageUrl = await uploadToCloudinary(imageFile, "music-app/songs");

const song = new Song({
title,
Expand All @@ -65,6 +48,12 @@ const createSong = async (req, res, next) => {
}, { new: true });
}

// Invalidate caches
await CacheManager.del("music-app:stats:global");
if (song.creator) await CacheManager.del(`music-app:stats:${song.creator}`);
await CacheManager.del(["music-app:songs:featured", "music-app:songs:for-you", "music-app:songs:trending"]);
await CacheManager.purgePattern("music-app:albums:*"); // Clear any album listings as they may change counts or content

res.status(201).json({
success: true,
message: "Song created successfully",
Expand Down Expand Up @@ -111,6 +100,12 @@ const deleteSong = async (req, res, next) => {

await Song.findByIdAndDelete(id);

// Invalidate caches
await CacheManager.del("music-app:stats:global");
if (song.creator) await CacheManager.del(`music-app:stats:${song.creator}`);
await CacheManager.del(["music-app:songs:featured", "music-app:songs:for-you", "music-app:songs:trending"]);
await CacheManager.purgePattern("music-app:albums:*");

res.status(200).json({
success: true,
message: "Song and associated media deleted successfully"
Expand Down Expand Up @@ -140,7 +135,7 @@ const createAlbum = async (req, res, next) => {
}

const imageFile = req.files.imageFile;
const imageUrl = await uploadToCloudinary(imageFile);
const imageUrl = await uploadToCloudinary(imageFile, "music-app/albums");

const album = new Album({
title,
Expand All @@ -152,6 +147,11 @@ const createAlbum = async (req, res, next) => {

await album.save();

// Invalidate caches
await CacheManager.del("music-app:stats:global");
if (album.creator) await CacheManager.del(`music-app:stats:${album.creator}`);
await CacheManager.purgePattern("music-app:albums:*");

res.status(201).json({
success: true,
message: "Album created successfully",
Expand Down Expand Up @@ -201,6 +201,12 @@ const deleteAlbum = async (req, res, next) => {
await Song.deleteMany({ albumId: id });
await Album.findByIdAndDelete(id);

// Invalidate caches
await CacheManager.del("music-app:stats:global");
if (album.creator) await CacheManager.del(`music-app:stats:${album.creator}`);
await CacheManager.del(["music-app:songs:featured", "music-app:songs:for-you", "music-app:songs:trending"]);
await CacheManager.purgePattern("music-app:albums:*");

res.status(200).json({
success: true,
message: "Album and all associated music and files deleted successfully"
Expand Down
Loading
Loading