Add create show endpoint with validation and error handling

This commit is contained in:
Nik Afiq 2025-12-06 18:31:19 +09:00
parent 9ef61fe8a6
commit 338403d80d
12 changed files with 689 additions and 30 deletions

View File

@ -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": { "delete": {
"description": "Delete a row from ` + "`" + `current` + "`" + ` by ID.", "description": "Delete a row from ` + "`" + `current` + "`" + ` by ID.",
"produces": [ "produces": [
@ -217,14 +261,72 @@ const docTemplate = `{
} }
}, },
"definitions": { "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": { "httpapi.CurrentResponse": {
"type": "object", "type": "object",
"properties": { "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": { "id": {
"type": "integer" "type": "integer",
"example": 123
},
"playback_length": {
"type": "string",
"example": "00:24:00"
},
"season_name": {
"type": "string",
"example": "Season 1"
}, },
"start_time": { "start_time": {
"type": "string" "type": "string",
"example": "10:00:00"
} }
} }
}, },
@ -238,7 +340,19 @@ const docTemplate = `{
} }
}, },
"httpapi.MoveReq": { "httpapi.MoveReq": {
"type": "object" "type": "object",
"properties": {
"id": {
"type": "integer",
"example": 123
},
"ids": {
"type": "array",
"items": {
"type": "integer"
}
}
}
}, },
"httpapi.MoveRes": { "httpapi.MoveRes": {
"type": "object", "type": "object",

View File

@ -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": { "delete": {
"description": "Delete a row from `current` by ID.", "description": "Delete a row from `current` by ID.",
"produces": [ "produces": [
@ -215,14 +259,72 @@
} }
}, },
"definitions": { "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": { "httpapi.CurrentResponse": {
"type": "object", "type": "object",
"properties": { "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": { "id": {
"type": "integer" "type": "integer",
"example": 123
},
"playback_length": {
"type": "string",
"example": "00:24:00"
},
"season_name": {
"type": "string",
"example": "Season 1"
}, },
"start_time": { "start_time": {
"type": "string" "type": "string",
"example": "10:00:00"
} }
} }
}, },
@ -236,7 +338,19 @@
} }
}, },
"httpapi.MoveReq": { "httpapi.MoveReq": {
"type": "object" "type": "object",
"properties": {
"id": {
"type": "integer",
"example": 123
},
"ids": {
"type": "array",
"items": {
"type": "integer"
}
}
}
}, },
"httpapi.MoveRes": { "httpapi.MoveRes": {
"type": "object", "type": "object",

View File

@ -1,10 +1,54 @@
basePath: / basePath: /
definitions: 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: httpapi.CurrentResponse:
properties: properties:
id: current_ep:
example: false
type: boolean
date_created:
example: "2024-02-01T15:04:05Z"
type: string
ep_num:
example: 1
type: integer 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: start_time:
example: "10:00:00"
type: string type: string
type: object type: object
httpapi.HTTPError: httpapi.HTTPError:
@ -14,6 +58,14 @@ definitions:
type: string type: string
type: object type: object
httpapi.MoveReq: httpapi.MoveReq:
properties:
id:
example: 123
type: integer
ids:
items:
type: integer
type: array
type: object type: object
httpapi.MoveRes: httpapi.MoveRes:
properties: properties:
@ -187,6 +239,35 @@ paths:
summary: List current shows summary: List current shows
tags: tags:
- shows - 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: schemes:
- http - http
- https - https

View File

@ -9,6 +9,8 @@ import (
var ( var (
ErrNotFound = errors.New("episode not found") ErrNotFound = errors.New("episode not found")
ErrInvalidStartTime = errors.New("invalid start_time (expected HH:MM:SS)") 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") ErrEmptyIDs = errors.New("ids must not be empty")
) )
@ -20,9 +22,19 @@ type Episode struct {
SeasonName string `json:"season_name"` SeasonName string `json:"season_name"`
StartTime string `json:"start_time"` StartTime string `json:"start_time"`
PlaybackLength string `json:"playback_length"` PlaybackLength string `json:"playback_length"`
CurrentEp bool `json:"current_ep"`
DateCreated time.Time `json:"date_created"` 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. // MoveResult describes what happened during an archive move operation.
type MoveResult struct { type MoveResult struct {
MovedIDs []int64 MovedIDs []int64
@ -34,6 +46,7 @@ type MoveResult struct {
type Repository interface { type Repository interface {
GetCurrent(ctx context.Context) (Episode, error) GetCurrent(ctx context.Context) (Episode, error)
SetCurrent(ctx context.Context, id int64, startHHMMSS string) 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) MoveToArchive(ctx context.Context, ids []int64) (MoveResult, error)
ListAll(ctx context.Context) ([]Episode, error) ListAll(ctx context.Context) ([]Episode, error)
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error

View File

@ -6,6 +6,7 @@ import "context"
type UseCases interface { type UseCases interface {
GetCurrent(ctx context.Context) (Episode, error) GetCurrent(ctx context.Context) (Episode, error)
SetCurrent(ctx context.Context, id int64, start string) (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) MoveToArchive(ctx context.Context, ids []int64) (MoveResult, error)
ListAll(ctx context.Context) ([]Episode, error) ListAll(ctx context.Context) ([]Episode, error)
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error

View File

@ -24,6 +24,7 @@ func NewRouter(svc episode.UseCases) *gin.Engine {
v1.GET("/current", getCurrentHandler(svc)) v1.GET("/current", getCurrentHandler(svc))
v1.POST("/current", setCurrentHandler(svc)) v1.POST("/current", setCurrentHandler(svc))
v1.POST("/archive", moveToArchiveHandler(svc)) v1.POST("/archive", moveToArchiveHandler(svc))
v1.POST("/shows", createShowHandler(svc))
v1.GET("/shows", listShowsHandler(svc)) v1.GET("/shows", listShowsHandler(svc))
v1.DELETE("/shows", deleteShowsHandler(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 // moveToArchiveHandler godoc
// @Summary Move shows to archive // @Summary Move shows to archive
// @Description Move one or many IDs from `current` to `current_archive`. // @Description Move one or many IDs from `current` to `current_archive`.

View File

@ -28,10 +28,13 @@ type fakeSvc struct {
listRes []episode.Episode listRes []episode.Episode
listErr error listErr error
deleteErr error deleteErr error
createRes episode.Episode
createErr error
lastSetID int64 lastSetID int64
lastTime string lastTime string
lastMove []int64 lastMove []int64
lastDelID int64 lastDelID int64
lastCreate episode.NewShowInput
} }
func (f *fakeSvc) GetCurrent(ctx context.Context) (episode.Episode, error) { 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 f.lastDelID = id
return f.deleteErr 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 ---- // ---- 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) { func TestListShows_OK(t *testing.T) {
svc := &fakeSvc{ svc := &fakeSvc{
listRes: []episode.Episode{ listRes: []episode.Episode{

View File

@ -1,5 +1,7 @@
package httpapi package httpapi
import "time"
// HTTPError is the standard error response. // HTTPError is the standard error response.
type HTTPError struct { type HTTPError struct {
Error string `json:"error" example:"invalid payload"` 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"` 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. // MoveReq is the request body for POST /api/v1/archive.
type MoveReq struct { type MoveReq struct {
ID *int64 `json:"id,omitempty" example:"123"` 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. // 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. // CurrentResponse represents a row from the `current` table.
//
// TODO: fill these fields to match repo.Current exactly.
type CurrentResponse struct { type CurrentResponse struct {
ID int64 `json:"id"` ID int `json:"id" example:"123"`
StartTime string `json:"start_time"` EpNum int `json:"ep_num" example:"1"`
// Add other fields here to mirror repo.Current 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"`
} }

View File

@ -35,6 +35,7 @@ SELECT
season_name, season_name,
to_char(start_time, 'HH24:MI:SS') AS start_time, to_char(start_time, 'HH24:MI:SS') AS start_time,
to_char(playback_length, 'HH24:MI:SS') AS playback_length, to_char(playback_length, 'HH24:MI:SS') AS playback_length,
current_ep,
date_created date_created
FROM current FROM current
ORDER BY id DESC; ORDER BY id DESC;
@ -48,7 +49,7 @@ ORDER BY id DESC;
var out []episode.Episode var out []episode.Episode
for rows.Next() { for rows.Next() {
var e episode.Episode 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 return nil, err
} }
out = append(out, e) out = append(out, e)
@ -65,6 +66,7 @@ SELECT
season_name, season_name,
to_char(start_time, 'HH24:MI:SS') AS start_time, to_char(start_time, 'HH24:MI:SS') AS start_time,
to_char(playback_length, 'HH24:MI:SS') AS playback_length, to_char(playback_length, 'HH24:MI:SS') AS playback_length,
current_ep,
date_created date_created
FROM current FROM current
WHERE current_ep = true WHERE current_ep = true
@ -73,7 +75,7 @@ LIMIT 1;
` `
var e episode.Episode var e episode.Episode
err := r.pool.QueryRow(ctx, q).Scan( 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@ -84,6 +86,30 @@ LIMIT 1;
return e, nil 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 { func (r *pgxEpisodeRepo) SetCurrent(ctx context.Context, id int64, startHHMMSS string) error {
tx, err := r.pool.Begin(ctx) tx, err := r.pool.Begin(ctx)
if err != nil { if err != nil {

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"reflect" "reflect"
"strings"
"testing" "testing"
"time" "time"
@ -73,6 +74,7 @@ func TestPGXEpisodeRepo_GetCurrent(t *testing.T) {
"S1", "S1",
"12:00:00", "12:00:00",
"00:24:00", "00:24:00",
true,
now, now,
}} }}
}, },
@ -94,8 +96,8 @@ func TestPGXEpisodeRepo_ListAll(t *testing.T) {
queryFn: func(ctx context.Context, sql string, args ...any) (pgx.Rows, error) { queryFn: func(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
return &fakeRows{ return &fakeRows{
rows: [][]any{ rows: [][]any{
{1, 1, "Pilot", "S1", "10:00: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", now}, {2, 2, "Next", "S1", "10:30:00", "00:24:00", false, now},
}, },
}, nil }, 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 --- // --- fakes ---
type fakePool struct { type fakePool struct {

View File

@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"regexp" "regexp"
"strings"
"time" "time"
"watch-party-backend/internal/core/episode" "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$`) 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) { func (s *Service) SetCurrent(ctx context.Context, id int64, start string) (episode.Episode, error) {
if !hhmmss.MatchString(start) { if !hhmmss.MatchString(start) {
return episode.Episode{}, episode.ErrInvalidStartTime 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) 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) { func (s *Service) MoveToArchive(ctx context.Context, ids []int64) (episode.MoveResult, error) {
uniq := make([]int64, 0, len(ids)) uniq := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids)) seen := make(map[int64]struct{}, len(ids))

View File

@ -25,6 +25,9 @@ type fakeRepo struct {
moveCalls [][]int64 moveCalls [][]int64
listRes []episode.Episode listRes []episode.Episode
listErr error listErr error
createRes episode.Episode
createErr error
createIn []episode.NewShowInput
} }
func (f *fakeRepo) GetCurrent(ctx context.Context) (episode.Episode, error) { 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 { func (f *fakeRepo) Delete(ctx context.Context, id int64) error {
return nil 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 ---- // ---- tests ----
@ -169,3 +176,69 @@ func TestEpisodeService_ListAll_Error(t *testing.T) {
t.Fatal("expected error") 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])
}
}