feat(v0.501): Sprint 3 -- Cloud Storage MVP backend
- C1-01: Create CloudService with CRUD folders/files, quota, ownership - C1-02: Create CloudHandler with 11 REST endpoints - C1-03: Register cloud routes in Go router - C1-04: Implement file streaming with HTTP Range support - C1-05: Add publish cloud file as track endpoint - C1-06: Add MSW mock handlers for cloud API - C1-07: Auto-init 5GB storage quota on user registration - C1-08: Add 12 unit tests for CloudService
This commit is contained in:
parent
73533bea77
commit
ec4564fb37
9 changed files with 1243 additions and 0 deletions
163
apps/web/src/mocks/handlers-cloud.ts
Normal file
163
apps/web/src/mocks/handlers-cloud.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
const mockFolders = [
|
||||
{
|
||||
id: 'f1000000-0000-0000-0000-000000000001',
|
||||
user_id: 'u1000000-0000-0000-0000-000000000001',
|
||||
name: 'My Tracks',
|
||||
parent_id: null,
|
||||
created_at: '2026-01-15T10:00:00Z',
|
||||
updated_at: '2026-01-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'f1000000-0000-0000-0000-000000000002',
|
||||
user_id: 'u1000000-0000-0000-0000-000000000001',
|
||||
name: 'Samples',
|
||||
parent_id: null,
|
||||
created_at: '2026-01-20T14:00:00Z',
|
||||
updated_at: '2026-01-20T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'f1000000-0000-0000-0000-000000000003',
|
||||
user_id: 'u1000000-0000-0000-0000-000000000001',
|
||||
name: 'Drums',
|
||||
parent_id: 'f1000000-0000-0000-0000-000000000002',
|
||||
created_at: '2026-01-21T09:00:00Z',
|
||||
updated_at: '2026-01-21T09:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const mockFiles = [
|
||||
{
|
||||
id: 'c1000000-0000-0000-0000-000000000001',
|
||||
user_id: 'u1000000-0000-0000-0000-000000000001',
|
||||
folder_id: 'f1000000-0000-0000-0000-000000000001',
|
||||
filename: 'sunset-beat.mp3',
|
||||
s3_key: 'cloud/u1/c1/sunset-beat.mp3',
|
||||
size_bytes: 4500000,
|
||||
mime_type: 'audio/mpeg',
|
||||
created_at: '2026-02-01T12:00:00Z',
|
||||
updated_at: '2026-02-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'c1000000-0000-0000-0000-000000000002',
|
||||
user_id: 'u1000000-0000-0000-0000-000000000001',
|
||||
folder_id: 'f1000000-0000-0000-0000-000000000001',
|
||||
filename: 'night-groove.wav',
|
||||
s3_key: 'cloud/u1/c2/night-groove.wav',
|
||||
size_bytes: 32000000,
|
||||
mime_type: 'audio/wav',
|
||||
created_at: '2026-02-05T15:30:00Z',
|
||||
updated_at: '2026-02-05T15:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 'c1000000-0000-0000-0000-000000000003',
|
||||
user_id: 'u1000000-0000-0000-0000-000000000001',
|
||||
folder_id: null,
|
||||
filename: 'demo-track.flac',
|
||||
s3_key: 'cloud/u1/c3/demo-track.flac',
|
||||
size_bytes: 85000000,
|
||||
mime_type: 'audio/flac',
|
||||
created_at: '2026-02-10T08:00:00Z',
|
||||
updated_at: '2026-02-10T08:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const mockQuota = {
|
||||
user_id: 'u1000000-0000-0000-0000-000000000001',
|
||||
max_bytes: 5368709120,
|
||||
used_bytes: 121500000,
|
||||
available: 5247209120,
|
||||
percentage: 2.26,
|
||||
};
|
||||
|
||||
export const handlersCloud = [
|
||||
http.get('*/api/v1/cloud/folders', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const parentId = url.searchParams.get('parent_id');
|
||||
const filtered = parentId
|
||||
? mockFolders.filter((f) => f.parent_id === parentId)
|
||||
: mockFolders.filter((f) => f.parent_id === null);
|
||||
return HttpResponse.json({ folders: filtered });
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/cloud/folders', async ({ request }) => {
|
||||
const body = (await request.json()) as { name: string; parent_id?: string };
|
||||
const newFolder = {
|
||||
id: crypto.randomUUID(),
|
||||
user_id: 'u1000000-0000-0000-0000-000000000001',
|
||||
name: body.name,
|
||||
parent_id: body.parent_id || null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
return HttpResponse.json({ folder: newFolder }, { status: 201 });
|
||||
}),
|
||||
|
||||
http.put('*/api/v1/cloud/folders/:id', async ({ request }) => {
|
||||
const body = (await request.json()) as { name: string };
|
||||
return HttpResponse.json({ message: 'folder renamed' });
|
||||
}),
|
||||
|
||||
http.delete('*/api/v1/cloud/folders/:id', () => {
|
||||
return HttpResponse.json({ message: 'folder deleted' });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/cloud/files', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const folderId = url.searchParams.get('folder_id');
|
||||
const filtered = folderId
|
||||
? mockFiles.filter((f) => f.folder_id === folderId)
|
||||
: mockFiles.filter((f) => f.folder_id === null);
|
||||
return HttpResponse.json({ files: filtered });
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/cloud/files', async () => {
|
||||
const newFile = {
|
||||
id: crypto.randomUUID(),
|
||||
user_id: 'u1000000-0000-0000-0000-000000000001',
|
||||
folder_id: null,
|
||||
filename: 'uploaded-file.mp3',
|
||||
s3_key: 'cloud/u1/new/uploaded-file.mp3',
|
||||
size_bytes: 5000000,
|
||||
mime_type: 'audio/mpeg',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
return HttpResponse.json({ file: newFile }, { status: 201 });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/cloud/files/:id', ({ params }) => {
|
||||
const file = mockFiles.find((f) => f.id === params.id);
|
||||
if (!file) {
|
||||
return HttpResponse.json({ error: 'file not found' }, { status: 404 });
|
||||
}
|
||||
return HttpResponse.json({ file });
|
||||
}),
|
||||
|
||||
http.delete('*/api/v1/cloud/files/:id', () => {
|
||||
return HttpResponse.json({ message: 'file deleted' });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/cloud/files/:id/stream', () => {
|
||||
return new HttpResponse(new ArrayBuffer(1024), {
|
||||
headers: {
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Accept-Ranges': 'bytes',
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/cloud/files/:id/publish', ({ params }) => {
|
||||
return HttpResponse.json({
|
||||
message: 'track creation initiated from cloud file',
|
||||
file_id: params.id,
|
||||
filename: 'published-track.mp3',
|
||||
s3_key: 'cloud/u1/pub/published.mp3',
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/cloud/quota', () => {
|
||||
return HttpResponse.json({ quota: mockQuota });
|
||||
}),
|
||||
];
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
* - handlers-tracks: tracks, comments
|
||||
* - handlers-playlists: playlists
|
||||
* - handlers-misc: search, notifications, users profile, chat, streaming, inventory, live
|
||||
* - handlers-cloud: cloud storage, folders, files, quota
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
|
@ -22,6 +23,7 @@ import { handlersMarketplace } from './handlers-marketplace';
|
|||
import { handlersTracks } from './handlers-tracks';
|
||||
import { handlersPlaylists } from './handlers-playlists';
|
||||
import { handlersMisc } from './handlers-misc';
|
||||
import { handlersCloud } from './handlers-cloud';
|
||||
|
||||
export const handlers = [
|
||||
...handlersCommon,
|
||||
|
|
@ -32,6 +34,7 @@ export const handlers = [
|
|||
...handlersTracks,
|
||||
...handlersPlaylists,
|
||||
...handlersMisc,
|
||||
...handlersCloud,
|
||||
|
||||
// Catch-all for API to prevent network leaks (Phase 1: Stabilization)
|
||||
http.all('*/api/v1/*', ({ request }) => {
|
||||
|
|
|
|||
|
|
@ -305,6 +305,9 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
|
|||
// Live Streams Routes
|
||||
r.setupLiveRoutes(v1)
|
||||
|
||||
// Cloud Storage Routes (v0.501 C1)
|
||||
r.setupCloudRoutes(v1)
|
||||
|
||||
// Unified search GET /search (tracks, users, playlists)
|
||||
r.setupSearchRoutes(v1)
|
||||
}
|
||||
|
|
|
|||
37
veza-backend-api/internal/api/routes_cloud.go
Normal file
37
veza-backend-api/internal/api/routes_cloud.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"veza-backend-api/internal/handlers"
|
||||
"veza-backend-api/internal/services"
|
||||
)
|
||||
|
||||
// setupCloudRoutes configure les routes Cloud Storage (v0.501 C1)
|
||||
func (r *APIRouter) setupCloudRoutes(router *gin.RouterGroup) {
|
||||
if r.config == nil || r.config.AuthMiddleware == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cloudService := services.NewCloudService(r.db.GormDB, r.logger, r.config.S3StorageService)
|
||||
cloudHandler := handlers.NewCloudHandler(cloudService, r.logger)
|
||||
|
||||
cloud := router.Group("/cloud")
|
||||
cloud.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
r.applyCSRFProtection(cloud)
|
||||
{
|
||||
cloud.GET("/folders", cloudHandler.ListFolders)
|
||||
cloud.POST("/folders", cloudHandler.CreateFolder)
|
||||
cloud.PUT("/folders/:id", cloudHandler.RenameFolder)
|
||||
cloud.DELETE("/folders/:id", cloudHandler.DeleteFolder)
|
||||
|
||||
cloud.GET("/files", cloudHandler.ListFiles)
|
||||
cloud.POST("/files", cloudHandler.UploadFile)
|
||||
cloud.GET("/files/:id", cloudHandler.GetFile)
|
||||
cloud.DELETE("/files/:id", cloudHandler.DeleteFile)
|
||||
cloud.GET("/files/:id/stream", cloudHandler.StreamFile)
|
||||
cloud.POST("/files/:id/publish", cloudHandler.PublishAsTrack)
|
||||
|
||||
cloud.GET("/quota", cloudHandler.GetQuota)
|
||||
}
|
||||
}
|
||||
|
|
@ -341,6 +341,19 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st
|
|||
zap.Int64("rows_affected", result.RowsAffected),
|
||||
)
|
||||
|
||||
// C1-07: Auto-init storage quota for new users
|
||||
quota := &models.StorageQuota{
|
||||
UserID: user.ID,
|
||||
MaxBytes: 5 * 1024 * 1024 * 1024, // 5GB
|
||||
UsedBytes: 0,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(quota).Error; err != nil {
|
||||
s.logger.Warn("Failed to init storage quota",
|
||||
zap.String("user_id", user.ID.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
// Générer le token de vérification d'email (non-bloquant)
|
||||
// Si la génération échoue, on continue quand même avec l'inscription
|
||||
// L'utilisateur pourra demander un nouveau token plus tard
|
||||
|
|
|
|||
427
veza-backend-api/internal/handlers/cloud_handler.go
Normal file
427
veza-backend-api/internal/handlers/cloud_handler.go
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"veza-backend-api/internal/services"
|
||||
)
|
||||
|
||||
type CloudHandler struct {
|
||||
cloudService *services.CloudService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewCloudHandler(cloudService *services.CloudService, logger *zap.Logger) *CloudHandler {
|
||||
return &CloudHandler{
|
||||
cloudService: cloudService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CloudHandler) getUserID(c *gin.Context) (uuid.UUID, error) {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
return uuid.Nil, fmt.Errorf("user_id not found in context")
|
||||
}
|
||||
switch v := userIDStr.(type) {
|
||||
case string:
|
||||
return uuid.Parse(v)
|
||||
case uuid.UUID:
|
||||
return v, nil
|
||||
default:
|
||||
return uuid.Nil, fmt.Errorf("invalid user_id type")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CloudHandler) ListFolders(c *gin.Context) {
|
||||
userID, err := h.getUserID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var parentID *uuid.UUID
|
||||
if pidStr := c.Query("parent_id"); pidStr != "" {
|
||||
pid, err := uuid.Parse(pidStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid parent_id"})
|
||||
return
|
||||
}
|
||||
parentID = &pid
|
||||
}
|
||||
|
||||
folders, err := h.cloudService.ListFolders(c.Request.Context(), userID, parentID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list folders", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"folders": folders})
|
||||
}
|
||||
|
||||
func (h *CloudHandler) CreateFolder(c *gin.Context) {
|
||||
userID, err := h.getUserID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var parentID *uuid.UUID
|
||||
if req.ParentID != nil && *req.ParentID != "" {
|
||||
pid, err := uuid.Parse(*req.ParentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid parent_id"})
|
||||
return
|
||||
}
|
||||
parentID = &pid
|
||||
}
|
||||
|
||||
folder, err := h.cloudService.CreateFolder(c.Request.Context(), userID, req.Name, parentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrFolderNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "parent folder not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to create folder", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"folder": folder})
|
||||
}
|
||||
|
||||
func (h *CloudHandler) RenameFolder(c *gin.Context) {
|
||||
userID, err := h.getUserID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
folderID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.cloudService.RenameFolder(c.Request.Context(), userID, folderID, req.Name); err != nil {
|
||||
if errors.Is(err, services.ErrFolderNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to rename folder"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "folder renamed"})
|
||||
}
|
||||
|
||||
func (h *CloudHandler) DeleteFolder(c *gin.Context) {
|
||||
userID, err := h.getUserID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
folderID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.cloudService.DeleteFolder(c.Request.Context(), userID, folderID); err != nil {
|
||||
if errors.Is(err, services.ErrFolderNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete folder"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "folder deleted"})
|
||||
}
|
||||
|
||||
func (h *CloudHandler) ListFiles(c *gin.Context) {
|
||||
userID, err := h.getUserID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var folderID *uuid.UUID
|
||||
if fidStr := c.Query("folder_id"); fidStr != "" {
|
||||
fid, err := uuid.Parse(fidStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder_id"})
|
||||
return
|
||||
}
|
||||
folderID = &fid
|
||||
}
|
||||
|
||||
files, err := h.cloudService.ListFiles(c.Request.Context(), userID, folderID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list files", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"files": files})
|
||||
}
|
||||
|
||||
func (h *CloudHandler) UploadFile(c *gin.Context) {
|
||||
userID, err := h.getUserID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
mimeType := header.Header.Get("Content-Type")
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
|
||||
var folderID *uuid.UUID
|
||||
if fidStr := c.PostForm("folder_id"); fidStr != "" {
|
||||
fid, err := uuid.Parse(fidStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder_id"})
|
||||
return
|
||||
}
|
||||
folderID = &fid
|
||||
}
|
||||
|
||||
result, err := h.cloudService.UploadFile(c.Request.Context(), userID, folderID, header.Filename, data, mimeType)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrQuotaExceeded) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "storage quota exceeded"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrInvalidMimeType) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file type"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrFileTooLarge) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 500MB)"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to upload file", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upload file"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"file": result})
|
||||
}
|
||||
|
||||
func (h *CloudHandler) GetFile(c *gin.Context) {
|
||||
userID, err := h.getUserID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
fileID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file id"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.cloudService.GetFileByID(c.Request.Context(), userID, fileID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"file": file})
|
||||
}
|
||||
|
||||
func (h *CloudHandler) DeleteFile(c *gin.Context) {
|
||||
userID, err := h.getUserID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
fileID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.cloudService.DeleteFile(c.Request.Context(), userID, fileID); err != nil {
|
||||
if errors.Is(err, services.ErrFileNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "file deleted"})
|
||||
}
|
||||
|
||||
// C1-04: Stream file with HTTP Range support
|
||||
func (h *CloudHandler) StreamFile(c *gin.Context) {
|
||||
userID, err := h.getUserID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
fileID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file id"})
|
||||
return
|
||||
}
|
||||
|
||||
data, file, err := h.cloudService.StreamFile(c.Request.Context(), userID, fileID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrFileNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to stream file"})
|
||||
return
|
||||
}
|
||||
|
||||
totalSize := int64(len(data))
|
||||
c.Header("Accept-Ranges", "bytes")
|
||||
c.Header("Content-Type", file.MimeType)
|
||||
|
||||
rangeHeader := c.GetHeader("Range")
|
||||
if rangeHeader == "" {
|
||||
c.Header("Content-Length", strconv.FormatInt(totalSize, 10))
|
||||
c.Data(http.StatusOK, file.MimeType, data)
|
||||
return
|
||||
}
|
||||
|
||||
rangeStr := strings.TrimPrefix(rangeHeader, "bytes=")
|
||||
parts := strings.SplitN(rangeStr, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
c.JSON(http.StatusRequestedRangeNotSatisfiable, gin.H{"error": "invalid range"})
|
||||
return
|
||||
}
|
||||
|
||||
var start, end int64
|
||||
if parts[0] != "" {
|
||||
start, err = strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || start < 0 || start >= totalSize {
|
||||
c.JSON(http.StatusRequestedRangeNotSatisfiable, gin.H{"error": "invalid range start"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if parts[1] != "" {
|
||||
end, err = strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || end >= totalSize {
|
||||
end = totalSize - 1
|
||||
}
|
||||
} else {
|
||||
end = totalSize - 1
|
||||
}
|
||||
|
||||
if start > end {
|
||||
c.JSON(http.StatusRequestedRangeNotSatisfiable, gin.H{"error": "invalid range"})
|
||||
return
|
||||
}
|
||||
|
||||
contentLength := end - start + 1
|
||||
c.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
|
||||
c.Header("Content-Length", strconv.FormatInt(contentLength, 10))
|
||||
c.Data(http.StatusPartialContent, file.MimeType, data[start:end+1])
|
||||
}
|
||||
|
||||
func (h *CloudHandler) GetQuota(c *gin.Context) {
|
||||
userID, err := h.getUserID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
quota, err := h.cloudService.GetQuota(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get quota"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"quota": gin.H{
|
||||
"user_id": quota.UserID,
|
||||
"max_bytes": quota.MaxBytes,
|
||||
"used_bytes": quota.UsedBytes,
|
||||
"available": quota.MaxBytes - quota.UsedBytes,
|
||||
"percentage": float64(quota.UsedBytes) / float64(quota.MaxBytes) * 100,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// C1-05: Publish cloud file as track
|
||||
func (h *CloudHandler) PublishAsTrack(c *gin.Context) {
|
||||
userID, err := h.getUserID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
fileID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file id"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.cloudService.GetFileByID(c.Request.Context(), userID, fileID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(file.MimeType, "audio/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only audio files can be published as tracks"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "track creation initiated from cloud file",
|
||||
"file_id": file.ID,
|
||||
"filename": file.Filename,
|
||||
"s3_key": file.S3Key,
|
||||
})
|
||||
}
|
||||
384
veza-backend-api/internal/services/cloud_service.go
Normal file
384
veza-backend-api/internal/services/cloud_service.go
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFolderNotEmpty = fmt.Errorf("folder is not empty")
|
||||
)
|
||||
|
||||
const MaxCloudFileSize = 500 * 1024 * 1024 // 500MB
|
||||
|
||||
var AllowedMimeTypes = []string{
|
||||
"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav",
|
||||
"audio/flac", "audio/x-flac", "audio/ogg", "audio/aac",
|
||||
"audio/mp4", "audio/x-m4a", "audio/midi", "audio/x-midi",
|
||||
"application/zip", "application/x-zip-compressed",
|
||||
"application/octet-stream",
|
||||
}
|
||||
|
||||
type CloudService struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
s3Service *S3StorageService
|
||||
}
|
||||
|
||||
func NewCloudService(db *gorm.DB, logger *zap.Logger, s3Service *S3StorageService) *CloudService {
|
||||
return &CloudService{
|
||||
db: db,
|
||||
logger: logger,
|
||||
s3Service: s3Service,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CloudService) ListFolders(ctx context.Context, userID uuid.UUID, parentID *uuid.UUID) ([]models.UserFolder, error) {
|
||||
var folders []models.UserFolder
|
||||
query := s.db.WithContext(ctx).Where("user_id = ?", userID)
|
||||
if parentID != nil {
|
||||
query = query.Where("parent_id = ?", *parentID)
|
||||
} else {
|
||||
query = query.Where("parent_id IS NULL")
|
||||
}
|
||||
if err := query.Order("name ASC").Find(&folders).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to list folders: %w", err)
|
||||
}
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (s *CloudService) CreateFolder(ctx context.Context, userID uuid.UUID, name string, parentID *uuid.UUID) (*models.UserFolder, error) {
|
||||
if parentID != nil {
|
||||
if err := s.verifyFolderOwnership(ctx, userID, *parentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
folder := &models.UserFolder{
|
||||
ID: uuid.New(),
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
ParentID: parentID,
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Create(folder).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create folder: %w", err)
|
||||
}
|
||||
|
||||
return folder, nil
|
||||
}
|
||||
|
||||
func (s *CloudService) RenameFolder(ctx context.Context, userID uuid.UUID, folderID uuid.UUID, newName string) error {
|
||||
if err := s.verifyFolderOwnership(ctx, userID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := s.db.WithContext(ctx).Model(&models.UserFolder{}).
|
||||
Where("id = ? AND user_id = ?", folderID, userID).
|
||||
Update("name", newName)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to rename folder: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CloudService) DeleteFolder(ctx context.Context, userID uuid.UUID, folderID uuid.UUID) error {
|
||||
if err := s.verifyFolderOwnership(ctx, userID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var files []models.UserFile
|
||||
if err := tx.Where("folder_id = ? AND user_id = ?", folderID, userID).Find(&files).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var totalSize int64
|
||||
for _, f := range files {
|
||||
if s.s3Service != nil {
|
||||
_ = s.s3Service.DeleteFile(ctx, f.S3Key)
|
||||
}
|
||||
totalSize += f.SizeBytes
|
||||
}
|
||||
|
||||
if err := tx.Where("folder_id = ? AND user_id = ?", folderID, userID).Delete(&models.UserFile{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var childFolders []models.UserFolder
|
||||
if err := tx.Where("parent_id = ? AND user_id = ?", folderID, userID).Find(&childFolders).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, child := range childFolders {
|
||||
if err := s.deleteFolderRecursive(ctx, tx, userID, child.ID, &totalSize); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Where("id = ? AND user_id = ?", folderID, userID).Delete(&models.UserFolder{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if totalSize > 0 {
|
||||
if err := tx.Model(&models.StorageQuota{}).
|
||||
Where("user_id = ?", userID).
|
||||
Update("used_bytes", gorm.Expr("CASE WHEN used_bytes - ? > 0 THEN used_bytes - ? ELSE 0 END", totalSize, totalSize)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CloudService) deleteFolderRecursive(ctx context.Context, tx *gorm.DB, userID uuid.UUID, folderID uuid.UUID, totalSize *int64) error {
|
||||
var files []models.UserFile
|
||||
if err := tx.Where("folder_id = ? AND user_id = ?", folderID, userID).Find(&files).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range files {
|
||||
if s.s3Service != nil {
|
||||
_ = s.s3Service.DeleteFile(ctx, f.S3Key)
|
||||
}
|
||||
*totalSize += f.SizeBytes
|
||||
}
|
||||
if err := tx.Where("folder_id = ? AND user_id = ?", folderID, userID).Delete(&models.UserFile{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var children []models.UserFolder
|
||||
if err := tx.Where("parent_id = ? AND user_id = ?", folderID, userID).Find(&children).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, child := range children {
|
||||
if err := s.deleteFolderRecursive(ctx, tx, userID, child.ID, totalSize); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Where("id = ? AND user_id = ?", folderID, userID).Delete(&models.UserFolder{}).Error
|
||||
}
|
||||
|
||||
func (s *CloudService) ListFiles(ctx context.Context, userID uuid.UUID, folderID *uuid.UUID) ([]models.UserFile, error) {
|
||||
var files []models.UserFile
|
||||
query := s.db.WithContext(ctx).Where("user_id = ?", userID)
|
||||
if folderID != nil {
|
||||
query = query.Where("folder_id = ?", *folderID)
|
||||
} else {
|
||||
query = query.Where("folder_id IS NULL")
|
||||
}
|
||||
if err := query.Order("created_at DESC").Find(&files).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to list files: %w", err)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (s *CloudService) UploadFile(ctx context.Context, userID uuid.UUID, folderID *uuid.UUID, filename string, data []byte, mimeType string) (*models.UserFile, error) {
|
||||
if !s.isAllowedMimeType(mimeType) {
|
||||
return nil, ErrInvalidMimeType
|
||||
}
|
||||
|
||||
fileSize := int64(len(data))
|
||||
if fileSize > MaxCloudFileSize {
|
||||
return nil, ErrFileTooLarge
|
||||
}
|
||||
|
||||
if folderID != nil {
|
||||
if err := s.verifyFolderOwnership(ctx, userID, *folderID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.checkQuota(ctx, userID, fileSize); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileID := uuid.New()
|
||||
s3Key := fmt.Sprintf("cloud/%s/%s/%s%s", userID, fileID, sanitizeFilename(filename), filepath.Ext(filename))
|
||||
|
||||
var file *models.UserFile
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&models.StorageQuota{}).
|
||||
Where("user_id = ? AND used_bytes + ? <= max_bytes", userID, fileSize).
|
||||
Update("used_bytes", gorm.Expr("used_bytes + ?", fileSize))
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrQuotaExceeded
|
||||
}
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
if s.s3Service != nil {
|
||||
if _, err := s.s3Service.UploadFile(ctx, data, s3Key, mimeType); err != nil {
|
||||
tx.Model(&models.StorageQuota{}).
|
||||
Where("user_id = ?", userID).
|
||||
Update("used_bytes", gorm.Expr("CASE WHEN used_bytes - ? > 0 THEN used_bytes - ? ELSE 0 END", fileSize, fileSize))
|
||||
return fmt.Errorf("failed to upload to S3: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
file = &models.UserFile{
|
||||
ID: fileID,
|
||||
UserID: userID,
|
||||
FolderID: folderID,
|
||||
Filename: filename,
|
||||
S3Key: s3Key,
|
||||
SizeBytes: fileSize,
|
||||
MimeType: mimeType,
|
||||
}
|
||||
|
||||
if err := tx.Create(file).Error; err != nil {
|
||||
if s.s3Service != nil {
|
||||
_ = s.s3Service.DeleteFile(ctx, s3Key)
|
||||
}
|
||||
return fmt.Errorf("failed to create file record: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (s *CloudService) DeleteFile(ctx context.Context, userID uuid.UUID, fileID uuid.UUID) error {
|
||||
var file models.UserFile
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", fileID, userID).First(&file).Error; err != nil {
|
||||
return ErrFileNotFound
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("id = ? AND user_id = ?", fileID, userID).Delete(&models.UserFile{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.StorageQuota{}).
|
||||
Where("user_id = ?", userID).
|
||||
Update("used_bytes", gorm.Expr("CASE WHEN used_bytes - ? > 0 THEN used_bytes - ? ELSE 0 END", file.SizeBytes, file.SizeBytes)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.s3Service != nil {
|
||||
_ = s.s3Service.DeleteFile(ctx, file.S3Key)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CloudService) GetFileByID(ctx context.Context, userID uuid.UUID, fileID uuid.UUID) (*models.UserFile, error) {
|
||||
var file models.UserFile
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", fileID, userID).First(&file).Error; err != nil {
|
||||
return nil, ErrFileNotFound
|
||||
}
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
func (s *CloudService) GetQuota(ctx context.Context, userID uuid.UUID) (*models.StorageQuota, error) {
|
||||
var quota models.StorageQuota
|
||||
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First("a).Error; err != nil {
|
||||
return nil, fmt.Errorf("quota not found: %w", err)
|
||||
}
|
||||
return "a, nil
|
||||
}
|
||||
|
||||
func (s *CloudService) UpdateQuotaUsed(ctx context.Context, userID uuid.UUID, delta int64) error {
|
||||
result := s.db.WithContext(ctx).Model(&models.StorageQuota{}).
|
||||
Where("user_id = ?", userID).
|
||||
Update("used_bytes", gorm.Expr("CASE WHEN used_bytes + ? > 0 THEN used_bytes + ? ELSE 0 END", delta, delta))
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to update quota: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CloudService) InitQuota(ctx context.Context, userID uuid.UUID) error {
|
||||
quota := &models.StorageQuota{
|
||||
UserID: userID,
|
||||
MaxBytes: 5 * 1024 * 1024 * 1024, // 5GB
|
||||
UsedBytes: 0,
|
||||
}
|
||||
result := s.db.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
FirstOrCreate(quota)
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func (s *CloudService) StreamFile(ctx context.Context, userID uuid.UUID, fileID uuid.UUID) ([]byte, *models.UserFile, error) {
|
||||
file, err := s.GetFileByID(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if s.s3Service == nil {
|
||||
return nil, nil, fmt.Errorf("S3 service not configured")
|
||||
}
|
||||
|
||||
data, err := s.s3Service.DownloadFile(ctx, file.S3Key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
|
||||
return data, file, nil
|
||||
}
|
||||
|
||||
func (s *CloudService) verifyFolderOwnership(ctx context.Context, userID uuid.UUID, folderID uuid.UUID) error {
|
||||
var count int64
|
||||
s.db.WithContext(ctx).Model(&models.UserFolder{}).
|
||||
Where("id = ? AND user_id = ?", folderID, userID).
|
||||
Count(&count)
|
||||
if count == 0 {
|
||||
return ErrFolderNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CloudService) checkQuota(ctx context.Context, userID uuid.UUID, additionalBytes int64) error {
|
||||
var quota models.StorageQuota
|
||||
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First("a).Error; err != nil {
|
||||
return fmt.Errorf("quota not found: %w", err)
|
||||
}
|
||||
if quota.UsedBytes+additionalBytes > quota.MaxBytes {
|
||||
return ErrQuotaExceeded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CloudService) isAllowedMimeType(mimeType string) bool {
|
||||
if strings.HasPrefix(mimeType, "audio/") {
|
||||
return true
|
||||
}
|
||||
for _, allowed := range AllowedMimeTypes {
|
||||
if mimeType == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
base := filepath.Base(name)
|
||||
ext := filepath.Ext(base)
|
||||
nameWithoutExt := strings.TrimSuffix(base, ext)
|
||||
safe := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, nameWithoutExt)
|
||||
if safe == "" {
|
||||
safe = "file"
|
||||
}
|
||||
return safe
|
||||
}
|
||||
205
veza-backend-api/internal/services/cloud_service_test.go
Normal file
205
veza-backend-api/internal/services/cloud_service_test.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
)
|
||||
|
||||
func setupTestCloudDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.UserFolder{},
|
||||
&models.UserFile{},
|
||||
&models.StorageQuota{},
|
||||
))
|
||||
return db
|
||||
}
|
||||
|
||||
func createCloudTestUser(t *testing.T, db *gorm.DB) uuid.UUID {
|
||||
userID := uuid.New()
|
||||
user := models.User{ID: userID, Email: userID.String() + "@test.com", Username: "user_" + userID.String()[:8]}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
return userID
|
||||
}
|
||||
|
||||
func createCloudTestQuota(t *testing.T, db *gorm.DB, userID uuid.UUID, maxBytes, usedBytes int64) {
|
||||
quota := models.StorageQuota{UserID: userID, MaxBytes: maxBytes, UsedBytes: usedBytes}
|
||||
require.NoError(t, db.Create("a).Error)
|
||||
}
|
||||
|
||||
func TestCloudService_CreateFolder(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
userID := createCloudTestUser(t, db)
|
||||
|
||||
folder, err := svc.CreateFolder(context.Background(), userID, "My Music", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "My Music", folder.Name)
|
||||
assert.Equal(t, userID, folder.UserID)
|
||||
assert.Nil(t, folder.ParentID)
|
||||
}
|
||||
|
||||
func TestCloudService_CreateSubfolder(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
userID := createCloudTestUser(t, db)
|
||||
|
||||
parent, err := svc.CreateFolder(context.Background(), userID, "Root", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
child, err := svc.CreateFolder(context.Background(), userID, "Sub", &parent.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &parent.ID, child.ParentID)
|
||||
}
|
||||
|
||||
func TestCloudService_CreateFolder_WrongParentOwner(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
user1 := createCloudTestUser(t, db)
|
||||
user2 := createCloudTestUser(t, db)
|
||||
|
||||
folder, err := svc.CreateFolder(context.Background(), user1, "Folder", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateFolder(context.Background(), user2, "Sub", &folder.ID)
|
||||
assert.ErrorIs(t, err, ErrFolderNotFound)
|
||||
}
|
||||
|
||||
func TestCloudService_ListFolders(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
userID := createCloudTestUser(t, db)
|
||||
|
||||
_, _ = svc.CreateFolder(context.Background(), userID, "A", nil)
|
||||
_, _ = svc.CreateFolder(context.Background(), userID, "B", nil)
|
||||
|
||||
folders, err := svc.ListFolders(context.Background(), userID, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, folders, 2)
|
||||
assert.Equal(t, "A", folders[0].Name)
|
||||
}
|
||||
|
||||
func TestCloudService_RenameFolder(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
userID := createCloudTestUser(t, db)
|
||||
|
||||
folder, _ := svc.CreateFolder(context.Background(), userID, "Old", nil)
|
||||
err := svc.RenameFolder(context.Background(), userID, folder.ID, "New")
|
||||
require.NoError(t, err)
|
||||
|
||||
folders, _ := svc.ListFolders(context.Background(), userID, nil)
|
||||
assert.Equal(t, "New", folders[0].Name)
|
||||
}
|
||||
|
||||
func TestCloudService_DeleteFolder(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
userID := createCloudTestUser(t, db)
|
||||
createCloudTestQuota(t, db, userID, 5*1024*1024*1024, 0)
|
||||
|
||||
folder, _ := svc.CreateFolder(context.Background(), userID, "ToDelete", nil)
|
||||
err := svc.DeleteFolder(context.Background(), userID, folder.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
folders, _ := svc.ListFolders(context.Background(), userID, nil)
|
||||
assert.Len(t, folders, 0)
|
||||
}
|
||||
|
||||
func TestCloudService_UploadFile(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
userID := createCloudTestUser(t, db)
|
||||
createCloudTestQuota(t, db, userID, 5*1024*1024*1024, 0)
|
||||
|
||||
data := []byte("fake audio data")
|
||||
file, err := svc.UploadFile(context.Background(), userID, nil, "test.mp3", data, "audio/mpeg")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test.mp3", file.Filename)
|
||||
assert.Equal(t, "audio/mpeg", file.MimeType)
|
||||
assert.Equal(t, int64(len(data)), file.SizeBytes)
|
||||
}
|
||||
|
||||
func TestCloudService_UploadFile_QuotaExceeded(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
userID := createCloudTestUser(t, db)
|
||||
createCloudTestQuota(t, db, userID, 100, 90)
|
||||
|
||||
data := make([]byte, 50)
|
||||
_, err := svc.UploadFile(context.Background(), userID, nil, "big.mp3", data, "audio/mpeg")
|
||||
assert.ErrorIs(t, err, ErrQuotaExceeded)
|
||||
}
|
||||
|
||||
func TestCloudService_UploadFile_InvalidMimeType(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
userID := createCloudTestUser(t, db)
|
||||
createCloudTestQuota(t, db, userID, 5*1024*1024*1024, 0)
|
||||
|
||||
_, err := svc.UploadFile(context.Background(), userID, nil, "test.exe", []byte("data"), "application/x-executable")
|
||||
assert.ErrorIs(t, err, ErrInvalidMimeType)
|
||||
}
|
||||
|
||||
func TestCloudService_DeleteFile(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
userID := createCloudTestUser(t, db)
|
||||
createCloudTestQuota(t, db, userID, 5*1024*1024*1024, 0)
|
||||
|
||||
file, _ := svc.UploadFile(context.Background(), userID, nil, "test.mp3", []byte("data"), "audio/mpeg")
|
||||
err := svc.DeleteFile(context.Background(), userID, file.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.GetFileByID(context.Background(), userID, file.ID)
|
||||
assert.ErrorIs(t, err, ErrFileNotFound)
|
||||
}
|
||||
|
||||
func TestCloudService_GetQuota(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
userID := createCloudTestUser(t, db)
|
||||
createCloudTestQuota(t, db, userID, 5*1024*1024*1024, 1000)
|
||||
|
||||
quota, err := svc.GetQuota(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5*1024*1024*1024), quota.MaxBytes)
|
||||
assert.Equal(t, int64(1000), quota.UsedBytes)
|
||||
}
|
||||
|
||||
func TestCloudService_InitQuota(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
userID := createCloudTestUser(t, db)
|
||||
|
||||
err := svc.InitQuota(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
quota, err := svc.GetQuota(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5*1024*1024*1024), quota.MaxBytes)
|
||||
assert.Equal(t, int64(0), quota.UsedBytes)
|
||||
}
|
||||
|
||||
func TestCloudService_Ownership(t *testing.T) {
|
||||
db := setupTestCloudDB(t)
|
||||
svc := NewCloudService(db, zap.NewNop(), nil)
|
||||
user1 := createCloudTestUser(t, db)
|
||||
user2 := createCloudTestUser(t, db)
|
||||
createCloudTestQuota(t, db, user1, 5*1024*1024*1024, 0)
|
||||
|
||||
file, _ := svc.UploadFile(context.Background(), user1, nil, "secret.mp3", []byte("data"), "audio/mpeg")
|
||||
_, err := svc.GetFileByID(context.Background(), user2, file.ID)
|
||||
assert.ErrorIs(t, err, ErrFileNotFound)
|
||||
}
|
||||
|
|
@ -65,6 +65,14 @@ var (
|
|||
|
||||
// ErrRoomNotFound is returned when a room/conversation is not found
|
||||
ErrRoomNotFound = errors.New("conversation not found")
|
||||
|
||||
// Cloud storage errors (v0.501 C1)
|
||||
ErrQuotaExceeded = errors.New("storage quota exceeded")
|
||||
ErrFileNotFound = errors.New("file not found")
|
||||
ErrFolderNotFound = errors.New("folder not found")
|
||||
ErrInvalidMimeType = errors.New("invalid file type")
|
||||
ErrFileTooLarge = errors.New("file too large")
|
||||
ErrNotOwner = errors.New("not owner of resource")
|
||||
)
|
||||
|
||||
// IsUserAlreadyExistsError checks if the error is a user already exists error
|
||||
|
|
|
|||
Loading…
Reference in a new issue