package biz import ( "context" "crypto/md5" "encoding/hex" "errors" "io" "strings" "testing" ) type listStorage struct { items []*StoredFile limits []int } func (*listStorage) Put(context.Context, string, io.Reader) (*StoredFile, error) { return nil, nil } func (*listStorage) Open(context.Context, string) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("")), nil } func (*listStorage) Delete(context.Context, string) error { return nil } func (*listStorage) Compose(context.Context, []string, string) (*StoredFile, string, error) { return nil, "", nil } func (*listStorage) DeletePrefix(context.Context, string) error { return nil } func (s *listStorage) List(_ context.Context, _ string, cursor string, limit int) ([]*StoredFile, string, bool, error) { s.limits = append(s.limits, limit) start := 0 for start < len(s.items) && s.items[start].Path <= cursor { start++ } end := start + limit if end > len(s.items) { end = len(s.items) } page := s.items[start:end] more := end < len(s.items) next := "" if len(page) > 0 { next = page[len(page)-1].Path } return page, next, more, nil } func TestListStorageFiltersChunksWithoutSkippingVisibleObjects(t *testing.T) { storage := &listStorage{items: []*StoredFile{ {Path: "uploads/chunks/1/00000000"}, {Path: "uploads/chunks/1/00000001"}, {Path: "visible/a"}, {Path: "visible/b"}, {Path: "visible/c"}, {Path: "visible/d"}, {Path: "visible/e"}, }} uc := NewMediaUsecase(nil, storage, nil) first, cursor, more, err := uc.ListStorage(context.Background(), "", "", 3) if err != nil { t.Fatal(err) } if got := []string{first[0].Path, first[1].Path, first[2].Path}; strings.Join(got, ",") != "visible/a,visible/b,visible/c" { t.Fatalf("first page = %v", got) } if cursor != "visible/c" || !more { t.Fatalf("first cursor/more = %q/%v", cursor, more) } if len(storage.limits) != 2 || storage.limits[0] != 3 || storage.limits[1] != 2 { t.Fatalf("storage limits = %v, want [3 2]", storage.limits) } second, cursor, more, err := uc.ListStorage(context.Background(), "", cursor, 3) if err != nil { t.Fatal(err) } if got := []string{second[0].Path, second[1].Path}; strings.Join(got, ",") != "visible/d,visible/e" { t.Fatalf("second page = %v", got) } if cursor != "visible/e" || more { t.Fatalf("second cursor/more = %q/%v", cursor, more) } } type mediaLimitSettings struct { RuntimeSettings max int64 } func (s mediaLimitSettings) MediaSettings() MediaSettings { return MediaSettings{MaxFileSize: s.max} } type mediaLimitStorage struct { readSize int64 reportedSize *int64 deleted bool composeCalled bool composed *StoredFile composedHash string } func (s *mediaLimitStorage) Put(_ context.Context, _ string, reader io.Reader) (*StoredFile, error) { data, err := io.ReadAll(reader) if err != nil { return nil, err } s.readSize = int64(len(data)) size := s.readSize if s.reportedSize != nil { size = *s.reportedSize } return &StoredFile{Size: size}, nil } func (*mediaLimitStorage) Open(context.Context, string) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("")), nil } func (s *mediaLimitStorage) Delete(context.Context, string) error { s.deleted = true return nil } func (s *mediaLimitStorage) Compose(context.Context, []string, string) (*StoredFile, string, error) { s.composeCalled = true return s.composed, s.composedHash, nil } func (*mediaLimitStorage) DeletePrefix(context.Context, string) error { return nil } func (*mediaLimitStorage) List(context.Context, string, string, int) ([]*StoredFile, string, bool, error) { return nil, "", false, nil } type mediaUploadRepo struct { MediaRepo session *UploadSession chunks []*UploadChunk claimed bool failed bool } func (r *mediaUploadRepo) FindUploadSession(context.Context, uint) (*UploadSession, error) { if r.session == nil { return nil, ErrUploadSessionNotFound } return r.session, nil } func (r *mediaUploadRepo) ClaimUploadSession(context.Context, uint) (bool, error) { return r.claimed, nil } func (r *mediaUploadRepo) FailUploadSession(context.Context, uint) error { r.failed = true return nil } func (r *mediaUploadRepo) ListChunks(context.Context, uint) ([]*UploadChunk, error) { return r.chunks, nil } func md5Text(value string) string { sum := md5.Sum([]byte(value)) return hex.EncodeToString(sum[:]) } func TestUploadRejectsDataPastConfiguredLimit(t *testing.T) { storage := &mediaLimitStorage{} uc := NewMediaUsecase(&mediaUploadRepo{}, storage, mediaLimitSettings{max: 4}) _, err := uc.Upload(context.Background(), 1, "sample.txt", "text/plain", 0, strings.NewReader("12345"), false) if !errors.Is(err, ErrMediaTooLarge) { t.Fatalf("Upload() error = %v, want ErrMediaTooLarge", err) } if storage.readSize != 5 || !storage.deleted { t.Fatalf("storage read/deleted = %d/%v, want 5/true", storage.readSize, storage.deleted) } } func TestUploadRejectsStorageSizeMismatch(t *testing.T) { reported := int64(3) storage := &mediaLimitStorage{reportedSize: &reported} uc := NewMediaUsecase(&mediaUploadRepo{}, storage, mediaLimitSettings{max: 4}) if _, err := uc.Upload(context.Background(), 1, "sample.txt", "text/plain", 0, strings.NewReader("1234"), false); err == nil { t.Fatal("Upload() error = nil, want storage size mismatch") } if !storage.deleted { t.Fatal("mismatched stored object was not deleted") } } func TestInitUploadRejectsInvalidLayout(t *testing.T) { uc := NewMediaUsecase(&mediaUploadRepo{}, &mediaLimitStorage{}, mediaLimitSettings{max: 10}) validHash := strings.Repeat("0", md5.Size*2) tests := []struct { name string hash string size int64 chunkSize int64 total int wantLarge bool }{ {name: "invalid hash", hash: "bad", size: 5, chunkSize: 3, total: 2}, {name: "zero size", hash: validHash, size: 0, chunkSize: 3, total: 1}, {name: "over limit", hash: validHash, size: 11, chunkSize: 3, total: 4, wantLarge: true}, {name: "wrong total", hash: validHash, size: 5, chunkSize: 3, total: 3}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, _, _, err := uc.InitUpload(context.Background(), 1, "sample.txt", test.hash, test.size, test.chunkSize, test.total) if err == nil { t.Fatal("InitUpload() error = nil") } if test.wantLarge && !errors.Is(err, ErrMediaTooLarge) { t.Fatalf("InitUpload() error = %v, want ErrMediaTooLarge", err) } }) } } func TestSaveChunkRejectsIndexAndSizeMismatch(t *testing.T) { repo := &mediaUploadRepo{session: &UploadSession{ID: 7, UserID: 1, FileSize: 5, ChunkSize: 3, ChunkTotal: 2, Status: "uploading"}} uc := NewMediaUsecase(repo, &mediaLimitStorage{}, mediaLimitSettings{max: 10}) if err := uc.SaveChunk(context.Background(), 1, 7, 2, md5Text("abc"), strings.NewReader("abc")); err == nil { t.Fatal("SaveChunk() accepted an out-of-range index") } if err := uc.SaveChunk(context.Background(), 1, 7, 0, md5Text("ab"), strings.NewReader("ab")); err == nil { t.Fatal("SaveChunk() accepted a short chunk") } if err := uc.SaveChunk(context.Background(), 1, 7, 0, md5Text("abcd"), strings.NewReader("abcd")); err == nil { t.Fatal("SaveChunk() accepted an oversized chunk") } } func TestCompleteUploadRejectsMismatchedChunkSizesBeforeCompose(t *testing.T) { repo := &mediaUploadRepo{ session: &UploadSession{ID: 7, UserID: 1, FileName: "sample.txt", FileHash: md5Text("abcde"), FileSize: 5, ChunkSize: 3, ChunkTotal: 2, Status: "uploading"}, chunks: []*UploadChunk{{Index: 0, Size: 3}, {Index: 1, Size: 1}}, claimed: true, } storage := &mediaLimitStorage{} uc := NewMediaUsecase(repo, storage, mediaLimitSettings{max: 10}) if _, err := uc.CompleteUpload(context.Background(), 1, 7, "text/plain"); err == nil { t.Fatal("CompleteUpload() accepted mismatched chunk sizes") } if storage.composeCalled { t.Fatal("Compose() was called before chunk-size validation completed") } if !repo.failed { t.Fatal("failed upload session was not returned to the uploading state") } }