From 338403d80d0b641480c11782b130a2f9044049de Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Sat, 6 Dec 2025 18:31:19 +0900 Subject: [PATCH] Add create show endpoint with validation and error handling --- backend/docs/docs.go | 120 +++++++++++++++++- backend/docs/swagger.json | 120 +++++++++++++++++- backend/docs/swagger.yaml | 83 +++++++++++- backend/internal/core/episode/model.go | 13 ++ backend/internal/core/episode/ports.go | 1 + backend/internal/http/handlers.go | 46 +++++++ backend/internal/http/handlers_test.go | 118 +++++++++++++++-- backend/internal/http/types.go | 26 +++- backend/internal/repo/episode_repo.go | 30 ++++- backend/internal/repo/episode_repo_test.go | 48 ++++++- backend/internal/service/episode_service.go | 41 ++++++ .../internal/service/episode_service_test.go | 73 +++++++++++ 12 files changed, 689 insertions(+), 30 deletions(-) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index d269b2a..45abec5 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -171,6 +171,50 @@ const docTemplate = `{ } } }, + "post": { + "description": "Insert a new show into ` + "`" + `current` + "`" + `.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "shows" + ], + "summary": "Create show", + "parameters": [ + { + "description": "New show payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.CreateShowReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/httpapi.CurrentResponse" + } + }, + "400": { + "description": "invalid payload or invalid time/duration", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "500": { + "description": "create failed", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + } + } + }, "delete": { "description": "Delete a row from ` + "`" + `current` + "`" + ` by ID.", "produces": [ @@ -217,14 +261,72 @@ const docTemplate = `{ } }, "definitions": { + "httpapi.CreateShowReq": { + "type": "object", + "required": [ + "ep_num", + "ep_title", + "playback_length", + "season_name", + "start_time" + ], + "properties": { + "ep_num": { + "type": "integer", + "example": 1 + }, + "ep_title": { + "type": "string", + "example": "Pilot" + }, + "playback_length": { + "type": "string", + "example": "00:24:00" + }, + "season_name": { + "type": "string", + "example": "Season 1" + }, + "start_time": { + "type": "string", + "example": "10:00:00" + } + } + }, "httpapi.CurrentResponse": { "type": "object", "properties": { + "current_ep": { + "type": "boolean", + "example": false + }, + "date_created": { + "type": "string", + "example": "2024-02-01T15:04:05Z" + }, + "ep_num": { + "type": "integer", + "example": 1 + }, + "ep_title": { + "type": "string", + "example": "Pilot" + }, "id": { - "type": "integer" + "type": "integer", + "example": 123 + }, + "playback_length": { + "type": "string", + "example": "00:24:00" + }, + "season_name": { + "type": "string", + "example": "Season 1" }, "start_time": { - "type": "string" + "type": "string", + "example": "10:00:00" } } }, @@ -238,7 +340,19 @@ const docTemplate = `{ } }, "httpapi.MoveReq": { - "type": "object" + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 123 + }, + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } }, "httpapi.MoveRes": { "type": "object", diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 2856d69..4d24cf6 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -169,6 +169,50 @@ } } }, + "post": { + "description": "Insert a new show into `current`.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "shows" + ], + "summary": "Create show", + "parameters": [ + { + "description": "New show payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.CreateShowReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/httpapi.CurrentResponse" + } + }, + "400": { + "description": "invalid payload or invalid time/duration", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "500": { + "description": "create failed", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + } + } + }, "delete": { "description": "Delete a row from `current` by ID.", "produces": [ @@ -215,14 +259,72 @@ } }, "definitions": { + "httpapi.CreateShowReq": { + "type": "object", + "required": [ + "ep_num", + "ep_title", + "playback_length", + "season_name", + "start_time" + ], + "properties": { + "ep_num": { + "type": "integer", + "example": 1 + }, + "ep_title": { + "type": "string", + "example": "Pilot" + }, + "playback_length": { + "type": "string", + "example": "00:24:00" + }, + "season_name": { + "type": "string", + "example": "Season 1" + }, + "start_time": { + "type": "string", + "example": "10:00:00" + } + } + }, "httpapi.CurrentResponse": { "type": "object", "properties": { + "current_ep": { + "type": "boolean", + "example": false + }, + "date_created": { + "type": "string", + "example": "2024-02-01T15:04:05Z" + }, + "ep_num": { + "type": "integer", + "example": 1 + }, + "ep_title": { + "type": "string", + "example": "Pilot" + }, "id": { - "type": "integer" + "type": "integer", + "example": 123 + }, + "playback_length": { + "type": "string", + "example": "00:24:00" + }, + "season_name": { + "type": "string", + "example": "Season 1" }, "start_time": { - "type": "string" + "type": "string", + "example": "10:00:00" } } }, @@ -236,7 +338,19 @@ } }, "httpapi.MoveReq": { - "type": "object" + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 123 + }, + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } }, "httpapi.MoveRes": { "type": "object", diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index f7aab59..4f676c9 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1,10 +1,54 @@ basePath: / definitions: + httpapi.CreateShowReq: + properties: + ep_num: + example: 1 + type: integer + ep_title: + example: Pilot + type: string + playback_length: + example: "00:24:00" + type: string + season_name: + example: Season 1 + type: string + start_time: + example: "10:00:00" + type: string + required: + - ep_num + - ep_title + - playback_length + - season_name + - start_time + type: object httpapi.CurrentResponse: properties: - id: + current_ep: + example: false + type: boolean + date_created: + example: "2024-02-01T15:04:05Z" + type: string + ep_num: + example: 1 type: integer + ep_title: + example: Pilot + type: string + id: + example: 123 + type: integer + playback_length: + example: "00:24:00" + type: string + season_name: + example: Season 1 + type: string start_time: + example: "10:00:00" type: string type: object httpapi.HTTPError: @@ -14,6 +58,14 @@ definitions: type: string type: object httpapi.MoveReq: + properties: + id: + example: 123 + type: integer + ids: + items: + type: integer + type: array type: object httpapi.MoveRes: properties: @@ -187,6 +239,35 @@ paths: summary: List current shows tags: - shows + post: + consumes: + - application/json + description: Insert a new show into `current`. + parameters: + - description: New show payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/httpapi.CreateShowReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/httpapi.CurrentResponse' + "400": + description: invalid payload or invalid time/duration + schema: + $ref: '#/definitions/httpapi.HTTPError' + "500": + description: create failed + schema: + $ref: '#/definitions/httpapi.HTTPError' + summary: Create show + tags: + - shows schemes: - http - https diff --git a/backend/internal/core/episode/model.go b/backend/internal/core/episode/model.go index b9f8fd2..9b8f280 100644 --- a/backend/internal/core/episode/model.go +++ b/backend/internal/core/episode/model.go @@ -9,6 +9,8 @@ import ( var ( ErrNotFound = errors.New("episode not found") ErrInvalidStartTime = errors.New("invalid start_time (expected HH:MM:SS)") + ErrInvalidPlayback = errors.New("invalid playback_length (expected HH:MM:SS and > 00:00:00)") + ErrInvalidShowInput = errors.New("invalid show payload") ErrEmptyIDs = errors.New("ids must not be empty") ) @@ -20,9 +22,19 @@ type Episode struct { SeasonName string `json:"season_name"` StartTime string `json:"start_time"` PlaybackLength string `json:"playback_length"` + CurrentEp bool `json:"current_ep"` DateCreated time.Time `json:"date_created"` } +// NewShowInput is the payload needed to create a new show/episode. +type NewShowInput struct { + EpNum int `json:"ep_num"` + EpTitle string `json:"ep_title"` + SeasonName string `json:"season_name"` + StartTime string `json:"start_time"` + PlaybackLength string `json:"playback_length"` +} + // MoveResult describes what happened during an archive move operation. type MoveResult struct { MovedIDs []int64 @@ -34,6 +46,7 @@ type MoveResult struct { type Repository interface { GetCurrent(ctx context.Context) (Episode, error) SetCurrent(ctx context.Context, id int64, startHHMMSS string) error + Create(ctx context.Context, in NewShowInput) (Episode, error) MoveToArchive(ctx context.Context, ids []int64) (MoveResult, error) ListAll(ctx context.Context) ([]Episode, error) Delete(ctx context.Context, id int64) error diff --git a/backend/internal/core/episode/ports.go b/backend/internal/core/episode/ports.go index 6a5518b..57a5081 100644 --- a/backend/internal/core/episode/ports.go +++ b/backend/internal/core/episode/ports.go @@ -6,6 +6,7 @@ import "context" type UseCases interface { GetCurrent(ctx context.Context) (Episode, error) SetCurrent(ctx context.Context, id int64, start string) (Episode, error) + Create(ctx context.Context, in NewShowInput) (Episode, error) MoveToArchive(ctx context.Context, ids []int64) (MoveResult, error) ListAll(ctx context.Context) ([]Episode, error) Delete(ctx context.Context, id int64) error diff --git a/backend/internal/http/handlers.go b/backend/internal/http/handlers.go index fa34e73..13ded5f 100644 --- a/backend/internal/http/handlers.go +++ b/backend/internal/http/handlers.go @@ -24,6 +24,7 @@ func NewRouter(svc episode.UseCases) *gin.Engine { v1.GET("/current", getCurrentHandler(svc)) v1.POST("/current", setCurrentHandler(svc)) v1.POST("/archive", moveToArchiveHandler(svc)) + v1.POST("/shows", createShowHandler(svc)) v1.GET("/shows", listShowsHandler(svc)) v1.DELETE("/shows", deleteShowsHandler(svc)) @@ -105,6 +106,51 @@ func setCurrentHandler(svc episode.UseCases) gin.HandlerFunc { } } +// createShowHandler godoc +// @Summary Create show +// @Description Insert a new show into `current`. +// @Tags shows +// @Accept json +// @Produce json +// @Param body body CreateShowReq true "New show payload" +// @Success 201 {object} CurrentResponse +// @Failure 400 {object} HTTPError "invalid payload or invalid time/duration" +// @Failure 500 {object} HTTPError "create failed" +// @Router /api/v1/shows [post] +func createShowHandler(svc episode.UseCases) gin.HandlerFunc { + return func(c *gin.Context) { + var req CreateShowReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + + in := episode.NewShowInput{ + EpNum: req.EpNum, + EpTitle: req.EpTitle, + SeasonName: req.SeasonName, + StartTime: req.StartTime, + PlaybackLength: req.PlaybackLength, + } + + item, err := svc.Create(c.Request.Context(), in) + if err != nil { + switch { + case errors.Is(err, episode.ErrInvalidStartTime), + errors.Is(err, episode.ErrInvalidPlayback), + errors.Is(err, episode.ErrInvalidShowInput): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "create failed"}) + return + } + } + + c.JSON(http.StatusCreated, item) + } +} + // moveToArchiveHandler godoc // @Summary Move shows to archive // @Description Move one or many IDs from `current` to `current_archive`. diff --git a/backend/internal/http/handlers_test.go b/backend/internal/http/handlers_test.go index f534532..a7a84a8 100644 --- a/backend/internal/http/handlers_test.go +++ b/backend/internal/http/handlers_test.go @@ -19,19 +19,22 @@ import ( // ---- fake service implementing episode.UseCases ---- type fakeSvc struct { - cur episode.Episode - getErr error - setCur episode.Episode - setErr error - moveRes episode.MoveResult - moveErr error - listRes []episode.Episode - listErr error - deleteErr error - lastSetID int64 - lastTime string - lastMove []int64 - lastDelID int64 + cur episode.Episode + getErr error + setCur episode.Episode + setErr error + moveRes episode.MoveResult + moveErr error + listRes []episode.Episode + listErr error + deleteErr error + createRes episode.Episode + createErr error + lastSetID int64 + lastTime string + lastMove []int64 + lastDelID int64 + lastCreate episode.NewShowInput } func (f *fakeSvc) GetCurrent(ctx context.Context) (episode.Episode, error) { @@ -55,6 +58,10 @@ func (f *fakeSvc) Delete(ctx context.Context, id int64) error { f.lastDelID = id return f.deleteErr } +func (f *fakeSvc) Create(ctx context.Context, in episode.NewShowInput) (episode.Episode, error) { + f.lastCreate = in + return f.createRes, f.createErr +} // ---- helpers ---- @@ -204,6 +211,91 @@ func TestPostArchive_SingleAndMultiple_OK(t *testing.T) { } } +func TestPostShows_BadPayload(t *testing.T) { + r := newRouterWithSvc(&fakeSvc{}) + w := doJSON(t, r, http.MethodPost, "/api/v1/shows", map[string]any{"ep_title": "Pilot"}) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestPostShows_ValidationErrors(t *testing.T) { + tests := []struct { + name string + err error + code int + payload any + }{ + { + name: "bad start", + err: episode.ErrInvalidStartTime, + code: http.StatusBadRequest, + payload: map[string]any{ + "ep_num": 1, "ep_title": "Pilot", "season_name": "S1", "start_time": "1:0:0", "playback_length": "00:24:00", + }, + }, + { + name: "bad playback", + err: episode.ErrInvalidPlayback, + code: http.StatusBadRequest, + payload: map[string]any{ + "ep_num": 1, "ep_title": "Pilot", "season_name": "S1", "start_time": "10:00:00", "playback_length": "0:0:0", + }, + }, + { + name: "service error", + err: errors.New("db down"), + code: http.StatusInternalServerError, + payload: map[string]any{ + "ep_num": 1, "ep_title": "Pilot", "season_name": "S1", "start_time": "10:00:00", "playback_length": "00:24:00", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &fakeSvc{createErr: tt.err} + r := newRouterWithSvc(svc) + w := doJSON(t, r, http.MethodPost, "/api/v1/shows", tt.payload) + if w.Code != tt.code { + t.Fatalf("expected %d, got %d body=%s", tt.code, w.Code, w.Body.String()) + } + }) + } +} + +func TestPostShows_OK(t *testing.T) { + now := time.Now().UTC() + want := episode.Episode{ + Id: 9, + EpNum: 1, + EpTitle: "Pilot", + SeasonName: "S1", + StartTime: "10:00:00", + PlaybackLength: "00:24:00", + CurrentEp: false, + DateCreated: now, + } + svc := &fakeSvc{createRes: want} + r := newRouterWithSvc(svc) + w := doJSON(t, r, http.MethodPost, "/api/v1/shows", map[string]any{ + "ep_num": 1, "ep_title": "Pilot", "season_name": "S1", "start_time": "10:00:00", "playback_length": "00:24:00", + }) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + var got episode.Episode + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("json: %v", err) + } + if got.Id != want.Id || got.CurrentEp { + t.Fatalf("unexpected body: %+v", got) + } + if svc.lastCreate.EpTitle != "Pilot" || svc.lastCreate.StartTime != "10:00:00" { + t.Fatalf("service called with wrong input: %+v", svc.lastCreate) + } +} + func TestListShows_OK(t *testing.T) { svc := &fakeSvc{ listRes: []episode.Episode{ diff --git a/backend/internal/http/types.go b/backend/internal/http/types.go index 9d15bb0..e549dd9 100644 --- a/backend/internal/http/types.go +++ b/backend/internal/http/types.go @@ -1,5 +1,7 @@ package httpapi +import "time" + // HTTPError is the standard error response. type HTTPError struct { Error string `json:"error" example:"invalid payload"` @@ -11,10 +13,19 @@ type SetCurrentReq struct { StartTime string `json:"start_time" binding:"required" example:"21:00:00"` } +// CreateShowReq is the request body for POST /api/v1/shows. +type CreateShowReq struct { + EpNum int `json:"ep_num" binding:"required" example:"1"` + EpTitle string `json:"ep_title" binding:"required" example:"Pilot"` + SeasonName string `json:"season_name" binding:"required" example:"Season 1"` + StartTime string `json:"start_time" binding:"required" example:"10:00:00"` + PlaybackLength string `json:"playback_length" binding:"required" example:"00:24:00"` +} + // MoveReq is the request body for POST /api/v1/archive. type MoveReq struct { ID *int64 `json:"id,omitempty" example:"123"` - IDs []int64 `json:"ids,omitempty" example:"[123,124]"` + IDs []int64 `json:"ids,omitempty"` } // MoveRes is the response body for POST /api/v1/archive. @@ -28,10 +39,13 @@ type MoveRes struct { } // CurrentResponse represents a row from the `current` table. -// -// TODO: fill these fields to match repo.Current exactly. type CurrentResponse struct { - ID int64 `json:"id"` - StartTime string `json:"start_time"` - // Add other fields here to mirror repo.Current + ID int `json:"id" example:"123"` + EpNum int `json:"ep_num" example:"1"` + EpTitle string `json:"ep_title" example:"Pilot"` + SeasonName string `json:"season_name" example:"Season 1"` + StartTime string `json:"start_time" example:"10:00:00"` + PlaybackLength string `json:"playback_length" example:"00:24:00"` + CurrentEp bool `json:"current_ep" example:"false"` + DateCreated time.Time `json:"date_created" example:"2024-02-01T15:04:05Z"` } diff --git a/backend/internal/repo/episode_repo.go b/backend/internal/repo/episode_repo.go index f00f45f..0ef7302 100644 --- a/backend/internal/repo/episode_repo.go +++ b/backend/internal/repo/episode_repo.go @@ -35,6 +35,7 @@ SELECT season_name, to_char(start_time, 'HH24:MI:SS') AS start_time, to_char(playback_length, 'HH24:MI:SS') AS playback_length, + current_ep, date_created FROM current ORDER BY id DESC; @@ -48,7 +49,7 @@ ORDER BY id DESC; var out []episode.Episode for rows.Next() { var e episode.Episode - if err := rows.Scan(&e.Id, &e.EpNum, &e.EpTitle, &e.SeasonName, &e.StartTime, &e.PlaybackLength, &e.DateCreated); err != nil { + if err := rows.Scan(&e.Id, &e.EpNum, &e.EpTitle, &e.SeasonName, &e.StartTime, &e.PlaybackLength, &e.CurrentEp, &e.DateCreated); err != nil { return nil, err } out = append(out, e) @@ -65,6 +66,7 @@ SELECT season_name, to_char(start_time, 'HH24:MI:SS') AS start_time, to_char(playback_length, 'HH24:MI:SS') AS playback_length, + current_ep, date_created FROM current WHERE current_ep = true @@ -73,7 +75,7 @@ LIMIT 1; ` var e episode.Episode err := r.pool.QueryRow(ctx, q).Scan( - &e.Id, &e.EpNum, &e.EpTitle, &e.SeasonName, &e.StartTime, &e.PlaybackLength, &e.DateCreated, + &e.Id, &e.EpNum, &e.EpTitle, &e.SeasonName, &e.StartTime, &e.PlaybackLength, &e.CurrentEp, &e.DateCreated, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -84,6 +86,30 @@ LIMIT 1; return e, nil } +func (r *pgxEpisodeRepo) Create(ctx context.Context, in episode.NewShowInput) (episode.Episode, error) { + const q = ` +INSERT INTO current (ep_num, ep_title, season_name, start_time, playback_length) +VALUES ($1, $2, $3, $4::time, $5::interval) +RETURNING + id, + ep_num, + ep_title, + season_name, + to_char(start_time, 'HH24:MI:SS') AS start_time, + to_char(playback_length, 'HH24:MI:SS') AS playback_length, + current_ep, + date_created; +` + var e episode.Episode + err := r.pool.QueryRow(ctx, q, in.EpNum, in.EpTitle, in.SeasonName, in.StartTime, in.PlaybackLength).Scan( + &e.Id, &e.EpNum, &e.EpTitle, &e.SeasonName, &e.StartTime, &e.PlaybackLength, &e.CurrentEp, &e.DateCreated, + ) + if err != nil { + return episode.Episode{}, err + } + return e, nil +} + func (r *pgxEpisodeRepo) SetCurrent(ctx context.Context, id int64, startHHMMSS string) error { tx, err := r.pool.Begin(ctx) if err != nil { diff --git a/backend/internal/repo/episode_repo_test.go b/backend/internal/repo/episode_repo_test.go index 9fc125a..d6c05cf 100644 --- a/backend/internal/repo/episode_repo_test.go +++ b/backend/internal/repo/episode_repo_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "reflect" + "strings" "testing" "time" @@ -73,6 +74,7 @@ func TestPGXEpisodeRepo_GetCurrent(t *testing.T) { "S1", "12:00:00", "00:24:00", + true, now, }} }, @@ -94,8 +96,8 @@ func TestPGXEpisodeRepo_ListAll(t *testing.T) { queryFn: func(ctx context.Context, sql string, args ...any) (pgx.Rows, error) { return &fakeRows{ rows: [][]any{ - {1, 1, "Pilot", "S1", "10:00:00", "00:24:00", now}, - {2, 2, "Next", "S1", "10:30:00", "00:24:00", now}, + {1, 1, "Pilot", "S1", "10:00:00", "00:24:00", true, now}, + {2, 2, "Next", "S1", "10:30:00", "00:24:00", false, now}, }, }, nil }, @@ -110,6 +112,48 @@ func TestPGXEpisodeRepo_ListAll(t *testing.T) { } } +func TestPGXEpisodeRepo_Create(t *testing.T) { + now := time.Now().UTC() + var gotSQL string + var gotArgs []any + fp := &fakePool{ + queryRowFn: func(ctx context.Context, sql string, args ...any) pgx.Row { + gotSQL = sql + gotArgs = append([]any(nil), args...) + return fakeRow{values: []any{ + 5, + 10, + "Title", + "S1", + "12:00:00", + "00:24:00", + false, + now, + }} + }, + } + repo := &pgxEpisodeRepo{pool: fp} + row, err := repo.Create(context.Background(), episode.NewShowInput{ + EpNum: 10, + EpTitle: "Title", + SeasonName: "S1", + StartTime: "12:00:00", + PlaybackLength: "00:24:00", + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if row.Id != 5 || !strings.Contains(gotSQL, "INSERT INTO current") { + t.Fatalf("bad insert: id=%d sql=%s", row.Id, gotSQL) + } + if len(gotArgs) != 5 || gotArgs[0] != 10 || gotArgs[4] != "00:24:00" { + t.Fatalf("bad args: %+v", gotArgs) + } + if row.CurrentEp { + t.Fatalf("expected current_ep false") + } +} + // --- fakes --- type fakePool struct { diff --git a/backend/internal/service/episode_service.go b/backend/internal/service/episode_service.go index fc160f8..f7c7a02 100644 --- a/backend/internal/service/episode_service.go +++ b/backend/internal/service/episode_service.go @@ -3,6 +3,7 @@ package service import ( "context" "regexp" + "strings" "time" "watch-party-backend/internal/core/episode" @@ -31,6 +32,26 @@ func (s *Service) GetCurrent(ctx context.Context) (episode.Episode, error) { var hhmmss = regexp.MustCompile(`^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$`) +func parseHHMMSSToDuration(hhmmss string) (time.Duration, error) { + parts := strings.Split(hhmmss, ":") + if len(parts) != 3 { + return 0, episode.ErrInvalidPlayback + } + hours, err := time.ParseDuration(parts[0] + "h") + if err != nil { + return 0, err + } + mins, err := time.ParseDuration(parts[1] + "m") + if err != nil { + return 0, err + } + secs, err := time.ParseDuration(parts[2] + "s") + if err != nil { + return 0, err + } + return hours + mins + secs, nil +} + func (s *Service) SetCurrent(ctx context.Context, id int64, start string) (episode.Episode, error) { if !hhmmss.MatchString(start) { return episode.Episode{}, episode.ErrInvalidStartTime @@ -43,6 +64,26 @@ func (s *Service) SetCurrent(ctx context.Context, id int64, start string) (episo return s.repo.GetCurrent(c) } +func (s *Service) Create(ctx context.Context, in episode.NewShowInput) (episode.Episode, error) { + if in.EpNum <= 0 || strings.TrimSpace(in.EpTitle) == "" || strings.TrimSpace(in.SeasonName) == "" { + return episode.Episode{}, episode.ErrInvalidShowInput + } + if !hhmmss.MatchString(in.StartTime) { + return episode.Episode{}, episode.ErrInvalidStartTime + } + if !hhmmss.MatchString(in.PlaybackLength) { + return episode.Episode{}, episode.ErrInvalidPlayback + } + dur, err := parseHHMMSSToDuration(in.PlaybackLength) + if err != nil || dur <= 0 { + return episode.Episode{}, episode.ErrInvalidPlayback + } + + c, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return s.repo.Create(c, in) +} + func (s *Service) MoveToArchive(ctx context.Context, ids []int64) (episode.MoveResult, error) { uniq := make([]int64, 0, len(ids)) seen := make(map[int64]struct{}, len(ids)) diff --git a/backend/internal/service/episode_service_test.go b/backend/internal/service/episode_service_test.go index 65576cc..e83e544 100644 --- a/backend/internal/service/episode_service_test.go +++ b/backend/internal/service/episode_service_test.go @@ -25,6 +25,9 @@ type fakeRepo struct { moveCalls [][]int64 listRes []episode.Episode listErr error + createRes episode.Episode + createErr error + createIn []episode.NewShowInput } func (f *fakeRepo) GetCurrent(ctx context.Context) (episode.Episode, error) { @@ -47,6 +50,10 @@ func (f *fakeRepo) ListAll(ctx context.Context) ([]episode.Episode, error) { func (f *fakeRepo) Delete(ctx context.Context, id int64) error { return nil } +func (f *fakeRepo) Create(ctx context.Context, in episode.NewShowInput) (episode.Episode, error) { + f.createIn = append(f.createIn, in) + return f.createRes, f.createErr +} // ---- tests ---- @@ -169,3 +176,69 @@ func TestEpisodeService_ListAll_Error(t *testing.T) { t.Fatal("expected error") } } + +func TestEpisodeService_Create_Validation(t *testing.T) { + tests := []struct { + name string + in episode.NewShowInput + want error + }{ + { + name: "bad ep num", + in: episode.NewShowInput{EpNum: 0, EpTitle: "x", SeasonName: "S1", StartTime: "10:00:00", PlaybackLength: "00:24:00"}, + want: episode.ErrInvalidShowInput, + }, + { + name: "blank title", + in: episode.NewShowInput{EpNum: 1, EpTitle: " ", SeasonName: "S1", StartTime: "10:00:00", PlaybackLength: "00:24:00"}, + want: episode.ErrInvalidShowInput, + }, + { + name: "bad start", + in: episode.NewShowInput{EpNum: 1, EpTitle: "x", SeasonName: "S1", StartTime: "1:0:0", PlaybackLength: "00:24:00"}, + want: episode.ErrInvalidStartTime, + }, + { + name: "bad playback format", + in: episode.NewShowInput{EpNum: 1, EpTitle: "x", SeasonName: "S1", StartTime: "10:00:00", PlaybackLength: "24:00"}, + want: episode.ErrInvalidPlayback, + }, + { + name: "zero playback", + in: episode.NewShowInput{EpNum: 1, EpTitle: "x", SeasonName: "S1", StartTime: "10:00:00", PlaybackLength: "00:00:00"}, + want: episode.ErrInvalidPlayback, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := service.NewEpisodeService(&fakeRepo{}) + if _, err := svc.Create(context.Background(), tt.in); !errors.Is(err, tt.want) { + t.Fatalf("expected %v, got %v", tt.want, err) + } + }) + } +} + +func TestEpisodeService_Create_OK(t *testing.T) { + want := episode.Episode{Id: 10, EpNum: 1, EpTitle: "Pilot", StartTime: "10:00:00", PlaybackLength: "00:24:00"} + fr := &fakeRepo{createRes: want} + svc := service.NewEpisodeService(fr) + + got, err := svc.Create(context.Background(), episode.NewShowInput{ + EpNum: 1, + EpTitle: "Pilot", + SeasonName: "S1", + StartTime: "10:00:00", + PlaybackLength: "00:24:00", + }) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + if got.Id != want.Id || len(fr.createIn) != 1 { + t.Fatalf("repo not called or bad id: %+v, calls=%d", got, len(fr.createIn)) + } + if fr.createIn[0].EpTitle != "Pilot" || fr.createIn[0].EpNum != 1 { + t.Fatalf("bad input passed: %+v", fr.createIn[0]) + } +}