From f8837fed9fc7eb858aa58aa07133f7b34038a52a Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Tue, 2 Dec 2025 20:47:46 +0900 Subject: [PATCH] Refactor episode management: introduce core episode model and update service/repo interfaces --- .gitignore | 1 + backend/internal/core/episode/model.go | 40 ++++++++++++++ backend/internal/http/handlers.go | 14 ++--- backend/internal/http/handlers_test.go | 49 +++++++++-------- backend/internal/repo/episode_repo.go | 53 +++++-------------- backend/internal/service/episode_service.go | 34 +++++------- .../internal/service/episode_service_test.go | 31 ++++++----- 7 files changed, 119 insertions(+), 103 deletions(-) create mode 100644 backend/internal/core/episode/model.go diff --git a/.gitignore b/.gitignore index 862215a..93ecf03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env +.cache .DS_Store */node_modules \ No newline at end of file diff --git a/backend/internal/core/episode/model.go b/backend/internal/core/episode/model.go new file mode 100644 index 0000000..3d95269 --- /dev/null +++ b/backend/internal/core/episode/model.go @@ -0,0 +1,40 @@ +package episode + +import ( + "context" + "errors" + "time" +) + +var ( + ErrNotFound = errors.New("episode not found") + ErrInvalidStartTime = errors.New("invalid start_time (expected HH:MM:SS)") + ErrEmptyIDs = errors.New("ids must not be empty") +) + +// Episode represents a single show/episode in the system. +type Episode struct { + Id int + EpNum int + EpTitle string + SeasonName string + StartTime string + PlaybackLength string + DateCreated time.Time +} + +// MoveResult describes what happened during an archive move operation. +type MoveResult struct { + MovedIDs []int64 + DeletedIDs []int64 + SkippedIDs []int64 +} + +// Repository is the storage port that the application layer depends on. +type Repository interface { + GetCurrent(ctx context.Context) (Episode, error) + SetCurrent(ctx context.Context, id int64, startHHMMSS string) 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 6eccd68..96dfd4b 100644 --- a/backend/internal/http/handlers.go +++ b/backend/internal/http/handlers.go @@ -6,7 +6,7 @@ import ( "strconv" "time" - "watch-party-backend/internal/repo" + "watch-party-backend/internal/core/episode" "watch-party-backend/internal/service" "github.com/gin-gonic/gin" @@ -58,7 +58,7 @@ func getCurrentHandler(svc service.EpisodeService) gin.HandlerFunc { return func(c *gin.Context) { cur, err := svc.GetCurrent(c.Request.Context()) if err != nil { - if err == repo.ErrNotFound { + if errors.Is(err, episode.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "no current row found"}) return } @@ -90,11 +90,11 @@ func setCurrentHandler(svc service.EpisodeService) gin.HandlerFunc { } cur, err := svc.SetCurrent(c.Request.Context(), req.ID, req.StartTime) if err != nil { - switch err { - case service.ErrInvalidTime: + switch { + case errors.Is(err, episode.ErrInvalidStartTime): c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return - case repo.ErrNotFound: + case errors.Is(err, episode.ErrNotFound): c.JSON(http.StatusNotFound, gin.H{"error": "id not found"}) return default: @@ -132,7 +132,7 @@ func moveToArchiveHandler(svc service.EpisodeService) gin.HandlerFunc { res, err := svc.MoveToArchive(c.Request.Context(), ids) if err != nil { - if err == service.ErrEmptyIDs { + if errors.Is(err, episode.ErrEmptyIDs) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -190,7 +190,7 @@ func deleteShowsHandler(svc service.EpisodeService) gin.HandlerFunc { } err = svc.Delete(c.Request.Context(), id) if err != nil { - if errors.Is(err, repo.ErrNotFound) { + if errors.Is(err, episode.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "id not found"}) return } diff --git a/backend/internal/http/handlers_test.go b/backend/internal/http/handlers_test.go index 97e56eb..28a6031 100644 --- a/backend/internal/http/handlers_test.go +++ b/backend/internal/http/handlers_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" + "watch-party-backend/internal/core/episode" httpapi "watch-party-backend/internal/http" - "watch-party-backend/internal/repo" "watch-party-backend/internal/service" "github.com/gin-gonic/gin" @@ -19,36 +19,39 @@ import ( // ---- fake service implementing service.EpisodeService ---- type fakeSvc struct { - cur repo.Episode + cur episode.Episode getErr error - setCur repo.Episode + setCur episode.Episode setErr error - moveRes repo.MoveResult + moveRes episode.MoveResult moveErr error lastSetID int64 lastTime string lastMove []int64 } -func (f *fakeSvc) GetCurrent(ctx context.Context) (repo.Episode, error) { +func (f *fakeSvc) GetCurrent(ctx context.Context) (episode.Episode, error) { return f.cur, f.getErr } -func (f *fakeSvc) SetCurrent(ctx context.Context, id int64, start string) (repo.Episode, error) { +func (f *fakeSvc) SetCurrent(ctx context.Context, id int64, start string) (episode.Episode, error) { f.lastSetID, f.lastTime = id, start return f.setCur, f.setErr } -func (f *fakeSvc) MoveToArchive(ctx context.Context, ids []int64) (repo.MoveResult, error) { +func (f *fakeSvc) MoveToArchive(ctx context.Context, ids []int64) (episode.MoveResult, error) { f.lastMove = append([]int64(nil), ids...) return f.moveRes, f.moveErr } -func (f *fakeSvc) ListAll(ctx context.Context) ([]repo.Episode, error) { +func (f *fakeSvc) ListAll(ctx context.Context) ([]episode.Episode, error) { if f.moveErr != nil { return nil, f.moveErr } - return []repo.Episode{ + return []episode.Episode{ {Id: 10, EpTitle: "X"}, }, nil } +func (f *fakeSvc) Delete(ctx context.Context, id int64) error { + return nil +} // ---- helpers ---- @@ -87,7 +90,7 @@ func TestHealthz_OK(t *testing.T) { func TestGetCurrent_V1_OK(t *testing.T) { now := time.Now().UTC() svc := &fakeSvc{ - cur: repo.Episode{ + cur: episode.Episode{ EpNum: 7, EpTitle: "Lucky", SeasonName: "S2", @@ -99,12 +102,12 @@ func TestGetCurrent_V1_OK(t *testing.T) { r := newRouterWithSvc(svc) w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/v1/current", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/current", nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("got %d", w.Code) } - var got repo.Episode + var got episode.Episode if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("json: %v", err) } @@ -116,16 +119,16 @@ func TestGetCurrent_V1_OK(t *testing.T) { func TestPostCurrent_BadPayload(t *testing.T) { r := newRouterWithSvc(&fakeSvc{}) // missing fields - w := doJSON(t, r, http.MethodPost, "/v1/current", map[string]any{"id": 1}) + w := doJSON(t, r, http.MethodPost, "/api/v1/current", map[string]any{"id": 1}) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestPostCurrent_BadTime(t *testing.T) { - svc := &fakeSvc{setErr: service.ErrInvalidTime} + svc := &fakeSvc{setErr: episode.ErrInvalidStartTime} r := newRouterWithSvc(svc) - w := doJSON(t, r, http.MethodPost, "/v1/current", map[string]any{ + w := doJSON(t, r, http.MethodPost, "/api/v1/current", map[string]any{ "id": 42, "start_time": "7:0:0", }) if w.Code != http.StatusBadRequest { @@ -134,16 +137,16 @@ func TestPostCurrent_BadTime(t *testing.T) { } func TestPostCurrent_OK(t *testing.T) { - want := repo.Episode{EpNum: 42, EpTitle: "Answer", StartTime: "21:07:05"} + want := episode.Episode{EpNum: 42, EpTitle: "Answer", StartTime: "21:07:05"} svc := &fakeSvc{setCur: want} r := newRouterWithSvc(svc) - w := doJSON(t, r, http.MethodPost, "/v1/current", map[string]any{ + w := doJSON(t, r, http.MethodPost, "/api/v1/current", map[string]any{ "id": 42, "start_time": "21:07:05", }) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d; body=%s", w.Code, w.Body.String()) } - var got repo.Episode + var got episode.Episode if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("json: %v", err) } @@ -156,9 +159,9 @@ func TestPostCurrent_OK(t *testing.T) { } func TestPostArchive_Empty(t *testing.T) { - svc := &fakeSvc{moveErr: service.ErrEmptyIDs} + svc := &fakeSvc{moveErr: episode.ErrEmptyIDs} r := newRouterWithSvc(svc) - w := doJSON(t, r, http.MethodPost, "/v1/archive", map[string]any{}) + w := doJSON(t, r, http.MethodPost, "/api/v1/archive", map[string]any{}) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d; body=%s", w.Code, w.Body.String()) } @@ -166,7 +169,7 @@ func TestPostArchive_Empty(t *testing.T) { func TestPostArchive_SingleAndMultiple_OK(t *testing.T) { svc := &fakeSvc{ - moveRes: repo.MoveResult{ + moveRes: episode.MoveResult{ MovedIDs: []int64{1, 2}, DeletedIDs: []int64{1, 2}, SkippedIDs: []int64{999}, @@ -175,12 +178,12 @@ func TestPostArchive_SingleAndMultiple_OK(t *testing.T) { r := newRouterWithSvc(svc) // single - w1 := doJSON(t, r, http.MethodPost, "/v1/archive", map[string]any{"id": 1}) + w1 := doJSON(t, r, http.MethodPost, "/api/v1/archive", map[string]any{"id": 1}) if w1.Code != http.StatusOK { t.Fatalf("got %d: %s", w1.Code, w1.Body.String()) } // multiple - w2 := doJSON(t, r, http.MethodPost, "/v1/archive", map[string]any{"ids": []int64{1, 2, 1}}) + w2 := doJSON(t, r, http.MethodPost, "/api/v1/archive", map[string]any{"ids": []int64{1, 2, 1}}) if w2.Code != http.StatusOK { t.Fatalf("got %d: %s", w2.Code, w2.Body.String()) } diff --git a/backend/internal/repo/episode_repo.go b/backend/internal/repo/episode_repo.go index e8f6cc4..846efe4 100644 --- a/backend/internal/repo/episode_repo.go +++ b/backend/internal/repo/episode_repo.go @@ -3,47 +3,22 @@ package repo import ( "context" "errors" - "time" + + "watch-party-backend/internal/core/episode" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) -var ErrNotFound = errors.New("not found") - -type Episode struct { - Id int `json:"id"` - 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"` - DateCreated time.Time `json:"date_created"` -} - -type MoveResult struct { - MovedIDs []int64 `json:"moved_ids"` - DeletedIDs []int64 `json:"deleted_ids"` - SkippedIDs []int64 `json:"skipped_ids"` -} - -type EpisodeRepository interface { - GetCurrent(ctx context.Context) (Episode, error) - SetCurrent(ctx context.Context, id int64, startHHMMSS string) error - MoveToArchive(ctx context.Context, ids []int64) (MoveResult, error) - ListAll(ctx context.Context) ([]Episode, error) - Delete(ctx context.Context, id int64) error -} - type pgxEpisodeRepo struct { pool *pgxpool.Pool } -func NewEpisodeRepo(pool *pgxpool.Pool) EpisodeRepository { +func NewEpisodeRepo(pool *pgxpool.Pool) episode.Repository { return &pgxEpisodeRepo{pool: pool} } -func (r *pgxEpisodeRepo) ListAll(ctx context.Context) ([]Episode, error) { +func (r *pgxEpisodeRepo) ListAll(ctx context.Context) ([]episode.Episode, error) { const q = ` SELECT id, @@ -62,9 +37,9 @@ ORDER BY id DESC; } defer rows.Close() - var out []Episode + var out []episode.Episode for rows.Next() { - var e 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 { return nil, err } @@ -73,7 +48,7 @@ ORDER BY id DESC; return out, rows.Err() } -func (r *pgxEpisodeRepo) GetCurrent(ctx context.Context) (Episode, error) { +func (r *pgxEpisodeRepo) GetCurrent(ctx context.Context) (episode.Episode, error) { const q = ` SELECT id, @@ -88,15 +63,15 @@ WHERE current_ep = true ORDER BY id DESC LIMIT 1; ` - var e Episode + 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, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return Episode{}, ErrNotFound + return episode.Episode{}, episode.ErrNotFound } - return Episode{}, err + return episode.Episode{}, err } return e, nil } @@ -119,14 +94,14 @@ func (r *pgxEpisodeRepo) SetCurrent(ctx context.Context, id int64, startHHMMSS s return err } if ct.RowsAffected() == 0 { - return ErrNotFound + return episode.ErrNotFound } return tx.Commit(ctx) } -func (r *pgxEpisodeRepo) MoveToArchive(ctx context.Context, ids []int64) (MoveResult, error) { - res := MoveResult{} +func (r *pgxEpisodeRepo) MoveToArchive(ctx context.Context, ids []int64) (episode.MoveResult, error) { + res := episode.MoveResult{} if len(ids) == 0 { return res, nil } @@ -207,7 +182,7 @@ func (r *pgxEpisodeRepo) Delete(ctx context.Context, id int64) error { return err } if cmdTag.RowsAffected() == 0 { - return ErrNotFound + return episode.ErrNotFound } return nil } diff --git a/backend/internal/service/episode_service.go b/backend/internal/service/episode_service.go index 2ef8897..4de7f94 100644 --- a/backend/internal/service/episode_service.go +++ b/backend/internal/service/episode_service.go @@ -2,41 +2,35 @@ package service import ( "context" - "errors" "regexp" "time" - "watch-party-backend/internal/repo" -) - -var ( - ErrInvalidTime = errors.New("invalid start_time (expected HH:MM:SS)") - ErrEmptyIDs = errors.New("ids must not be empty") + "watch-party-backend/internal/core/episode" ) type EpisodeService interface { - GetCurrent(ctx context.Context) (repo.Episode, error) - SetCurrent(ctx context.Context, id int64, start string) (repo.Episode, error) - MoveToArchive(ctx context.Context, ids []int64) (repo.MoveResult, error) - ListAll(ctx context.Context) ([]repo.Episode, error) + GetCurrent(ctx context.Context) (episode.Episode, error) + SetCurrent(ctx context.Context, id int64, start string) (episode.Episode, error) + MoveToArchive(ctx context.Context, ids []int64) (episode.MoveResult, error) + ListAll(ctx context.Context) ([]episode.Episode, error) Delete(ctx context.Context, id int64) error } type episodeService struct { - repo repo.EpisodeRepository + repo episode.Repository } -func NewEpisodeService(r repo.EpisodeRepository) EpisodeService { +func NewEpisodeService(r episode.Repository) EpisodeService { return &episodeService{repo: r} } -func (s *episodeService) ListAll(ctx context.Context) ([]repo.Episode, error) { +func (s *episodeService) ListAll(ctx context.Context) ([]episode.Episode, error) { c, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() return s.repo.ListAll(c) } -func (s *episodeService) GetCurrent(ctx context.Context) (repo.Episode, error) { +func (s *episodeService) GetCurrent(ctx context.Context) (episode.Episode, error) { c, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() return s.repo.GetCurrent(c) @@ -44,19 +38,19 @@ func (s *episodeService) GetCurrent(ctx context.Context) (repo.Episode, error) { var hhmmss = regexp.MustCompile(`^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$`) -func (s *episodeService) SetCurrent(ctx context.Context, id int64, start string) (repo.Episode, error) { +func (s *episodeService) SetCurrent(ctx context.Context, id int64, start string) (episode.Episode, error) { if !hhmmss.MatchString(start) { - return repo.Episode{}, ErrInvalidTime + return episode.Episode{}, episode.ErrInvalidStartTime } c, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := s.repo.SetCurrent(c, id, start); err != nil { - return repo.Episode{}, err + return episode.Episode{}, err } return s.repo.GetCurrent(c) } -func (s *episodeService) MoveToArchive(ctx context.Context, ids []int64) (repo.MoveResult, error) { +func (s *episodeService) MoveToArchive(ctx context.Context, ids []int64) (episode.MoveResult, error) { uniq := make([]int64, 0, len(ids)) seen := make(map[int64]struct{}, len(ids)) for _, id := range ids { @@ -66,7 +60,7 @@ func (s *episodeService) MoveToArchive(ctx context.Context, ids []int64) (repo.M } } if len(uniq) == 0 { - return repo.MoveResult{}, ErrEmptyIDs + return episode.MoveResult{}, episode.ErrEmptyIDs } c, cancel := context.WithTimeout(ctx, 10*time.Second) diff --git a/backend/internal/service/episode_service_test.go b/backend/internal/service/episode_service_test.go index 4122992..65576cc 100644 --- a/backend/internal/service/episode_service_test.go +++ b/backend/internal/service/episode_service_test.go @@ -6,28 +6,28 @@ import ( "testing" "time" - "watch-party-backend/internal/repo" + "watch-party-backend/internal/core/episode" "watch-party-backend/internal/service" ) // ---- test fakes ---- type fakeRepo struct { - cur repo.Episode + cur episode.Episode getErr error setErr error - moveRes repo.MoveResult + moveRes episode.MoveResult moveErr error setCalls []struct { id int64 start string } moveCalls [][]int64 - listRes []repo.Episode + listRes []episode.Episode listErr error } -func (f *fakeRepo) GetCurrent(ctx context.Context) (repo.Episode, error) { +func (f *fakeRepo) GetCurrent(ctx context.Context) (episode.Episode, error) { return f.cur, f.getErr } func (f *fakeRepo) SetCurrent(ctx context.Context, id int64, start string) error { @@ -37,20 +37,23 @@ func (f *fakeRepo) SetCurrent(ctx context.Context, id int64, start string) error }{id, start}) return f.setErr } -func (f *fakeRepo) MoveToArchive(ctx context.Context, ids []int64) (repo.MoveResult, error) { +func (f *fakeRepo) MoveToArchive(ctx context.Context, ids []int64) (episode.MoveResult, error) { f.moveCalls = append(f.moveCalls, ids) return f.moveRes, f.moveErr } -func (f *fakeRepo) ListAll(ctx context.Context) ([]repo.Episode, error) { +func (f *fakeRepo) ListAll(ctx context.Context) ([]episode.Episode, error) { return f.listRes, f.listErr } +func (f *fakeRepo) Delete(ctx context.Context, id int64) error { + return nil +} // ---- tests ---- func TestEpisodeService_GetCurrent_OK(t *testing.T) { now := time.Now().UTC() fr := &fakeRepo{ - cur: repo.Episode{ + cur: episode.Episode{ EpNum: 1, EpTitle: "Pilot", SeasonName: "S1", @@ -83,8 +86,8 @@ func TestEpisodeService_SetCurrent_RejectsBadTime(t *testing.T) { svc := service.NewEpisodeService(fr) _, err := svc.SetCurrent(context.Background(), 42, "7:0:0") // not zero-padded - if err == nil || !errors.Is(err, service.ErrInvalidTime) { - t.Fatalf("expected ErrInvalidTime, got %v", err) + if err == nil || !errors.Is(err, episode.ErrInvalidStartTime) { + t.Fatalf("expected ErrInvalidStartTime, got %v", err) } if len(fr.setCalls) != 0 { t.Fatalf("repo should not be called on invalid time") @@ -94,7 +97,7 @@ func TestEpisodeService_SetCurrent_RejectsBadTime(t *testing.T) { func TestEpisodeService_SetCurrent_OK(t *testing.T) { now := time.Now().UTC() fr := &fakeRepo{ - cur: repo.Episode{ + cur: episode.Episode{ EpNum: 42, EpTitle: "Answer", SeasonName: "S1", @@ -121,14 +124,14 @@ func TestEpisodeService_MoveToArchive_EmptyIDs(t *testing.T) { fr := &fakeRepo{} svc := service.NewEpisodeService(fr) _, err := svc.MoveToArchive(context.Background(), nil) - if err == nil || !errors.Is(err, service.ErrEmptyIDs) { + if err == nil || !errors.Is(err, episode.ErrEmptyIDs) { t.Fatalf("expected ErrEmptyIDs, got %v", err) } } func TestEpisodeService_MoveToArchive_DedupAndOK(t *testing.T) { fr := &fakeRepo{ - moveRes: repo.MoveResult{ + moveRes: episode.MoveResult{ MovedIDs: []int64{1, 2}, DeletedIDs: []int64{1, 2}, SkippedIDs: []int64{999}, @@ -148,7 +151,7 @@ func TestEpisodeService_MoveToArchive_DedupAndOK(t *testing.T) { } func TestEpisodeService_ListAll_OK(t *testing.T) { - fr := &fakeRepo{listRes: []repo.Episode{{Id: 3}, {Id: 1}}} + fr := &fakeRepo{listRes: []episode.Episode{{Id: 3}, {Id: 1}}} svc := service.NewEpisodeService(fr) got, err := svc.ListAll(context.Background()) if err != nil {