Add create show endpoint with validation and error handling
This commit is contained in:
parent
9ef61fe8a6
commit
338403d80d
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user