Skip to content
Open
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
14 changes: 14 additions & 0 deletions .hoplite/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 1,
"ports": {"preview": 3000, "additional": {}},
"scripts": {
"setup": {
"enabled": true,
"command": "command -v git-lfs >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq git-lfs); mise use -g go@1.25.0 >/dev/null 2>&1; export PATH=\"$HOME/.local/share/mise/shims:$PATH\"; python3 -m venv .venv && .venv/bin/pip install -q --upgrade pip && .venv/bin/pip install -q 'psycopg[binary]' psycopg-pool redis Pillow google-cloud-storage boto3 python-magic pydantic structlog attrs pytest && go mod download"
},
"run": {"enabled": true, "command": null},
"archive": {"enabled": true, "command": null},
"check": {"enabled": true, "command": null}
},
"mcpServers": []
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.25.0

require (
cloud.google.com/go/storage v1.58.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/aws/aws-sdk-go-v2 v1.42.0
github.com/aws/aws-sdk-go-v2/config v1.32.25
github.com/aws/aws-sdk-go-v2/credentials v1.19.24
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
Expand Down Expand Up @@ -145,6 +147,7 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
Expand Down
218 changes: 218 additions & 0 deletions internal/handler/asset_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package handler

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rndmcodeguy20/mpiper/internal/config"
"github.com/rndmcodeguy20/mpiper/internal/models"
"github.com/rndmcodeguy20/mpiper/internal/service"
"go.uber.org/zap"
)

const testMaxAssetSize = 1024 * 1024 // 1 MiB

type stubService struct {
createFn func(ctx context.Context, req models.UploadAssetRequest) (*models.UploadAssetResponse, error)
markFn func(ctx context.Context, id uuid.UUID) error
}

func (s *stubService) CreateAsset(ctx context.Context, req models.UploadAssetRequest) (*models.UploadAssetResponse, error) {
return s.createFn(ctx, req)
}

func (s *stubService) MarkAssetUploaded(ctx context.Context, id uuid.UUID) error {
return s.markFn(ctx, id)
}

func initHandlerConfig(t *testing.T) {
t.Helper()
config.Init(config.EnvConfig{MaxAssetSizeBytes: testMaxAssetSize})
}

func newTestHandler(svc service.AssetService) *AssetHandler {
return NewAssetHandler(svc, zap.NewNop(), nil)
}

func doRequest(t *testing.T, h *AssetHandler, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.CreateAsset(w, req)
return w
}

func TestCreateAssetRejectsMissingContentType(t *testing.T) {
initHandlerConfig(t)
h := newTestHandler(&stubService{})

w := doRequest(t, h, http.MethodPost, "/api/v1/storage/presign", `{"fileName":"a.jpg","size":10}`)

if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}

func TestCreateAssetRejectsUnsupportedContentType(t *testing.T) {
initHandlerConfig(t)
h := newTestHandler(&stubService{})

w := doRequest(t, h, http.MethodPost, "/api/v1/storage/presign", `{"fileName":"a.gif","contentType":"image/gif","size":10}`)

if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}

func TestCreateAssetRejectsOversizedAsset(t *testing.T) {
initHandlerConfig(t)
var called bool
h := newTestHandler(&stubService{createFn: func(ctx context.Context, req models.UploadAssetRequest) (*models.UploadAssetResponse, error) {
called = true
return nil, nil
}})

body := `{"fileName":"a.jpg","contentType":"image/jpeg","size":` + "2097152" + `}`
w := doRequest(t, h, http.MethodPost, "/api/v1/storage/presign", body)

if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
if called {
t.Error("service was called for an oversized asset; validation must short-circuit")
}
}

func TestCreateAssetRejectsMalformedJSON(t *testing.T) {
initHandlerConfig(t)
h := newTestHandler(&stubService{})

w := doRequest(t, h, http.MethodPost, "/api/v1/storage/presign", `{"fileName":`)

if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}

func TestCreateAssetServiceErrorReturns500(t *testing.T) {
initHandlerConfig(t)
h := newTestHandler(&stubService{createFn: func(ctx context.Context, req models.UploadAssetRequest) (*models.UploadAssetResponse, error) {
return nil, context.DeadlineExceeded
}})

w := doRequest(t, h, http.MethodPost, "/api/v1/storage/presign", `{"fileName":"a.jpg","contentType":"image/jpeg","size":10}`)

if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", w.Code)
}
}

func TestCreateAssetSuccess(t *testing.T) {
initHandlerConfig(t)
var got models.UploadAssetRequest
h := newTestHandler(&stubService{createFn: func(ctx context.Context, req models.UploadAssetRequest) (*models.UploadAssetResponse, error) {
got = req
return &models.UploadAssetResponse{
UploadUrl: "https://presigned.example/put",
AssetID: "11111111-1111-1111-1111-111111111111",
Method: "PUT",
Headers: map[string]string{"Content-Type": "image/jpeg"},
ObjectPath: "a.jpg",
PublicUrl: "https://cdn.example/a.jpg",
ExpiresAt: 300,
}, nil
}})

w := doRequest(t, h, http.MethodPost, "/api/v1/storage/presign", `{"fileName":"a.jpg","contentType":"image/jpeg","size":1234}`)

if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if got.ContentType != "image/jpeg" || got.FileName != "a.jpg" || got.Size != 1234 {
t.Errorf("service received %+v, want the parsed request", got)
}
var resp struct {
Status string `json:"status"`
Data struct {
UploadURL string `json:"uploadUrl"`
ExpiresAt int64 `json:"expiresAt"`
ObjectPath string `json:"objectPath"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
if resp.Status != "success" || resp.Data.UploadURL == "" || resp.Data.ExpiresAt != 300 || resp.Data.ObjectPath != "a.jpg" {
t.Errorf("unexpected success payload: %+v", resp)
}
}

func markUploadedRequest(t *testing.T, h *AssetHandler, assetID string) *httptest.ResponseRecorder {
t.Helper()
r := chi.NewRouter()
r.Get("/api/v1/assets/{assetID}/complete", h.MarkAssetUploaded)
req := httptest.NewRequest(http.MethodGet, "/api/v1/assets/"+assetID+"/complete", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}

func TestMarkAssetUploadedRejectsMissingID(t *testing.T) {
initHandlerConfig(t)
h := newTestHandler(&stubService{})

w := markUploadedRequest(t, h, "")

if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}

func TestMarkAssetUploadedRejectsInvalidUUID(t *testing.T) {
initHandlerConfig(t)
h := newTestHandler(&stubService{})

w := markUploadedRequest(t, h, "not-a-uuid")

if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}

func TestMarkAssetUploadedServiceErrorReturns500(t *testing.T) {
initHandlerConfig(t)
h := newTestHandler(&stubService{markFn: func(ctx context.Context, id uuid.UUID) error {
return context.DeadlineExceeded
}})

w := markUploadedRequest(t, h, "11111111-1111-1111-1111-111111111111")

if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", w.Code)
}
}

func TestMarkAssetUploadedSuccess(t *testing.T) {
initHandlerConfig(t)
var gotID uuid.UUID
h := newTestHandler(&stubService{markFn: func(ctx context.Context, id uuid.UUID) error {
gotID = id
return nil
}})

w := markUploadedRequest(t, h, "11111111-1111-1111-1111-111111111111")

if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if gotID.String() != "11111111-1111-1111-1111-111111111111" {
t.Errorf("service received %s, want the parsed uuid", gotID)
}
}
Loading