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": {
"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",

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": {
"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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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`.

View File

@ -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{

View File

@ -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"`
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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))

View File

@ -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])
}
}