diff --git a/e2e/reviewDrafts.spec.ts b/e2e/reviewDrafts.spec.ts new file mode 100644 index 00000000..b765ad9c --- /dev/null +++ b/e2e/reviewDrafts.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from "./fixtures"; +import { populateDummyData } from "./helpers/db"; +import { loginAsUserA } from "./helpers/login"; + +test.beforeEach(async ({ page }) => { + await populateDummyData(page); + await loginAsUserA(page); +}); + +test("auto-saves a draft and lets the user resume", async ({ page }) => { + await page.getByRole("button", { name: "Add Rating" }).click(); + + await page.getByPlaceholder("Search").pressSequentially("slow lear"); + await page.getByText("Slow Learners (2015)").click(); + + const draftResponse = page.waitForResponse( + (response) => + response.url().includes("/reviews/drafts") && + ["POST", "PUT"].includes(response.request().method()) && + response.ok() + ); + await page.getByLabel("When did you watch?").fill("2024-10-10"); + await page.getByLabel("Other thoughts?").fill("Draft thoughts"); + await draftResponse; + + await page.getByRole("menuitem", { name: "Account" }).click(); + await page.getByRole("menuitem", { name: "Drafts" }).click(); + + await expect(page).toHaveURL("/reviews/drafts"); + + const draftCard = page.getByTestId("draft-card").first(); + await expect(draftCard.getByText("Slow Learners")).toBeVisible(); + await draftCard.getByRole("link", { name: "Continue" }).click(); + + await expect(page.getByLabel("Other thoughts?")).toHaveValue( + "Draft thoughts" + ); + + await page.getByRole("button", { name: "Publish" }).click(); + await expect(page).toHaveURL(/\/movies\/3(#review\d+)?/); +}); + +test("redirects to an existing draft when starting the same review", async ({ + page, +}) => { + await page.getByRole("button", { name: "Add Rating" }).click(); + + await page.getByPlaceholder("Search").pressSequentially("slow lear"); + await page.getByText("Slow Learners (2015)").click(); + + const draftResponse = page.waitForResponse( + (response) => + response.url().includes("/reviews/drafts") && + ["POST", "PUT"].includes(response.request().method()) && + response.ok() + ); + await page.getByLabel("When did you watch?").fill("2024-10-10"); + await page.getByLabel("Other thoughts?").fill("Second draft"); + await draftResponse; + + await page.getByRole("menuitem", { name: "Home" }).click(); + await page.getByRole("button", { name: "Add Rating" }).click(); + + await page.getByPlaceholder("Search").pressSequentially("slow lear"); + await page.getByText("Slow Learners (2015)").click(); + + await expect(page).toHaveURL(/\/reviews\/\d+\/edit/); + await expect(page.locator("h2 .badge", { hasText: "Draft" })).toBeVisible(); +}); diff --git a/e2e/reviews.spec.ts b/e2e/reviews.spec.ts index 7248f384..c66e5a3a 100644 --- a/e2e/reviews.spec.ts +++ b/e2e/reviews.spec.ts @@ -153,7 +153,7 @@ test("adds a new movie rating and fills in only required fields", async ({ await page.getByLabel("Rating").selectOption({ label: "1.0" }); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -204,7 +204,7 @@ Haley Joel Osment is going places! I can't believe Bruce Willis was a ghost the whole time!`); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -248,7 +248,7 @@ test("adds a new TV show rating and fills in only required fields", async ({ await page.getByLabel("Rating").selectOption({ label: "4.5" }); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/tv-shows/2?season=5"); @@ -308,7 +308,7 @@ test("views a TV show with an existing review and adds a new review", async ({ await page.getByLabel("Rating").selectOption({ label: "5.0" }); await page.getByLabel("Other thoughts?").fill("I liked it, too!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/tv-shows/1?season=2"); }); @@ -345,7 +345,7 @@ Daniel Radcliffe is **fantastic**, and it's a great film role for Rainn Wilson. You'll like it if you enjoy things like _Children's Hospital_, _Comedy Bang Bang_, or _Popstar_.`); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -430,7 +430,7 @@ test("adds a new rating and fills all fields", async ({ page }) => { await page.getByLabel("Other thoughts?").fill("My favorite movie!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -486,7 +486,7 @@ test("HTML tags in reviews are stripped from review excerpt", async ({ .getByLabel("Other thoughts?") .fill("This is the best movie ever!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -523,7 +523,7 @@ test("adds a new movie rating and edits the details", async ({ page }) => { await page.getByLabel("Other thoughts?").fill("My favorite movie!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -601,7 +601,7 @@ test("adds a new rating and cancels the edit", async ({ page }) => { .getByLabel("Other thoughts?") .fill("What an English patient he was!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -663,7 +663,7 @@ test("editing another user's review fails", async ({ .getByLabel("Other thoughts?") .fill("What an English patient he was!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -706,7 +706,7 @@ test("views a movie with an existing review and adds a new review", async ({ await page.getByLabel("Rating").selectOption({ label: "5.0" }); await page.getByLabel("Other thoughts?").fill("Relevant as ever"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/1"); }); @@ -929,7 +929,7 @@ test("adds a new movie rating without a numeric rating", async ({ page }) => { .getByLabel("Other thoughts?") .fill("A classic, but I don't want to give it a numeric rating."); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); diff --git a/handlers/reviews.go b/handlers/reviews.go index 8432b476..2e262286 100644 --- a/handlers/reviews.go +++ b/handlers/reviews.go @@ -28,42 +28,16 @@ type reviewPutRequest struct { func (s Server) reviewsPost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - req, err := parseReviewPostRequest(r) + req, err := parseReviewPostRequest(r, true) if err != nil { http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) log.Printf("couldn't parse review POST request: %v", err) return } - review := screenjournal.Review{ - Owner: mustGetUsernameFromContext(r.Context()), - TvShowSeason: req.TvShowSeason, - Rating: req.Rating, - Watched: req.WatchDate, - Blurb: req.Blurb, - Comments: []screenjournal.ReviewComment{}, - } - - if req.MediaType == screenjournal.MediaTypeMovie { - review.Movie, err = s.moviefromTmdbID(s.store, req.TmdbID) - if err == store.ErrMovieNotFound { - http.Error(w, fmt.Sprintf("Could not find movie with TMDB ID: %v", req.TmdbID), http.StatusNotFound) - return - } else if err != nil { - log.Printf("failed to get local media ID for movie with TMDB ID %v: %v", req.TmdbID, err) - http.Error(w, fmt.Sprintf("Failed to look up movie with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) - return - } - } else if req.MediaType == screenjournal.MediaTypeTvShow { - review.TvShow, err = s.tvShowfromTmdbID(s.store, req.TmdbID) - if err == store.ErrTvShowNotFound { - http.Error(w, fmt.Sprintf("Could not find tv show with TMDB ID: %v", req.TmdbID), http.StatusNotFound) - return - } else if err != nil { - log.Printf("failed to get local media ID for TV show with TMDB ID %v: %v", req.TmdbID, err) - http.Error(w, fmt.Sprintf("Failed to look up TV show with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) - return - } + review, ok := s.reviewFromPostRequest(w, req, mustGetUsernameFromContext(r.Context()), false) + if !ok { + return } review.ID, err = s.store.InsertReview(review) @@ -75,12 +49,7 @@ func (s Server) reviewsPost() http.HandlerFunc { s.announcer.AnnounceNewReview(review) - if review.MediaType() == screenjournal.MediaTypeMovie { - http.Redirect(w, r, fmt.Sprintf("/movies/%d#review%d", review.Movie.ID.Int64(), review.ID.UInt64()), http.StatusSeeOther) - } else { - http.Redirect(w, r, fmt.Sprintf("/tv-shows/%d?season=%d#review%d", review.TvShow.ID.Int64(), review.TvShowSeason.UInt8(), review.ID.UInt64()), http.StatusSeeOther) - } - + http.Redirect(w, r, publishedReviewRoute(review), http.StatusSeeOther) } } @@ -92,22 +61,12 @@ func (s Server) reviewsPut() http.HandlerFunc { return } - review, err := s.store.ReadReview(id) - if err == store.ErrReviewNotFound { - http.Error(w, "Review not found", http.StatusNotFound) - return - } else if err != nil { - http.Error(w, fmt.Sprintf("Failed to read review: %v", err), http.StatusInternalServerError) + review, ok := s.loadOwnedReview(w, r, id) + if !ok { return } - loggedInUsername := mustGetUsernameFromContext(r.Context()) - if !review.Owner.Equal(loggedInUsername) { - http.Error(w, "You can't edit another user's review", http.StatusForbidden) - return - } - - parsedRequest, err := parseReviewPutRequest(r) + parsedRequest, err := parseReviewPutRequest(r, true) if err != nil { http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) return @@ -116,6 +75,8 @@ func (s Server) reviewsPut() http.HandlerFunc { review.Rating = parsedRequest.Rating review.Blurb = parsedRequest.Blurb review.Watched = parsedRequest.Watched + wasDraft := review.IsDraft + review.IsDraft = false if err := s.store.UpdateReview(review); err != nil { log.Printf("failed to update review: %v", err) @@ -123,13 +84,11 @@ func (s Server) reviewsPut() http.HandlerFunc { return } - var newRoute string - if review.MediaType() == screenjournal.MediaTypeMovie { - newRoute = fmt.Sprintf("/movies/%d", review.Movie.ID.Int64()) - } else { - newRoute = fmt.Sprintf("/tv-shows/%d?season=%d", review.TvShow.ID.Int64(), review.TvShowSeason.UInt8()) + if wasDraft && !review.IsDraft { + s.announcer.AnnounceNewReview(review) } - http.Redirect(w, r, newRoute, http.StatusSeeOther) + + http.Redirect(w, r, publishedReviewRoute(review), http.StatusSeeOther) } } @@ -166,7 +125,10 @@ func (s Server) reviewsDelete() http.HandlerFunc { } } -func parseReviewPostRequest(r *http.Request) (reviewPostRequest, error) { +// parseReviewPostRequest parses the fields shared by published reviews and +// drafts. When requireWatchDate is false (drafts), an empty watch date is +// allowed so that incomplete drafts can still be saved. +func parseReviewPostRequest(r *http.Request, requireWatchDate bool) (reviewPostRequest, error) { if err := r.ParseForm(); err != nil { log.Printf("failed to decode review POST request: %v", err) return reviewPostRequest{}, err @@ -193,7 +155,7 @@ func parseReviewPostRequest(r *http.Request) (reviewPostRequest, error) { return reviewPostRequest{}, err } - if parsed.WatchDate, err = parse.WatchDate(r.PostFormValue("watch-date")); err != nil { + if parsed.WatchDate, err = parseWatchDate(r, requireWatchDate); err != nil { return reviewPostRequest{}, err } @@ -204,7 +166,9 @@ func parseReviewPostRequest(r *http.Request) (reviewPostRequest, error) { return parsed, nil } -func parseReviewPutRequest(r *http.Request) (reviewPutRequest, error) { +// parseReviewPutRequest parses an update to an existing review or draft. When +// requireWatchDate is false (drafts), an empty watch date is allowed. +func parseReviewPutRequest(r *http.Request, requireWatchDate bool) (reviewPutRequest, error) { if err := r.ParseForm(); err != nil { log.Printf("failed to decode review PUT request: %v", err) return reviewPutRequest{}, err @@ -216,7 +180,7 @@ func parseReviewPutRequest(r *http.Request) (reviewPutRequest, error) { return reviewPutRequest{}, err } - if parsed.Watched, err = parse.WatchDate(r.PostFormValue("watch-date")); err != nil { + if parsed.Watched, err = parseWatchDate(r, requireWatchDate); err != nil { return reviewPutRequest{}, err } @@ -227,6 +191,92 @@ func parseReviewPutRequest(r *http.Request) (reviewPutRequest, error) { return parsed, nil } +// parseWatchDate parses the watch-date form field. When the field is optional +// (drafts) and empty, it returns the zero WatchDate without error. +func parseWatchDate(r *http.Request, required bool) (screenjournal.WatchDate, error) { + raw := r.PostFormValue("watch-date") + if !required && raw == "" { + return screenjournal.WatchDate{}, nil + } + return parse.WatchDate(raw) +} + +// formBool reports whether the named form field holds a truthy value. +func formBool(r *http.Request, field string) bool { + v := r.PostFormValue(field) + return v == "true" || v == "1" +} + +// loadOwnedReview reads the review at id and verifies the logged-in user owns +// it. On any failure it writes the appropriate error response and returns +// ok=false. +func (s Server) loadOwnedReview(w http.ResponseWriter, r *http.Request, id screenjournal.ReviewID) (screenjournal.Review, bool) { + review, err := s.store.ReadReview(id) + if err == store.ErrReviewNotFound { + http.Error(w, "Review not found", http.StatusNotFound) + return screenjournal.Review{}, false + } else if err != nil { + http.Error(w, fmt.Sprintf("Failed to read review: %v", err), http.StatusInternalServerError) + return screenjournal.Review{}, false + } + + if !review.Owner.Equal(mustGetUsernameFromContext(r.Context())) { + http.Error(w, "You can't edit another user's review", http.StatusForbidden) + return screenjournal.Review{}, false + } + + return review, true +} + +// reviewFromPostRequest builds a Review from a parsed POST request, resolving +// its movie or TV show from the TMDB ID. On any failure it writes the +// appropriate error response and returns ok=false. +func (s Server) reviewFromPostRequest(w http.ResponseWriter, req reviewPostRequest, owner screenjournal.Username, isDraft bool) (screenjournal.Review, bool) { + review := screenjournal.Review{ + Owner: owner, + TvShowSeason: req.TvShowSeason, + Rating: req.Rating, + Watched: req.WatchDate, + Blurb: req.Blurb, + IsDraft: isDraft, + Comments: []screenjournal.ReviewComment{}, + } + + var err error + if req.MediaType == screenjournal.MediaTypeMovie { + review.Movie, err = s.moviefromTmdbID(s.store, req.TmdbID) + if err == store.ErrMovieNotFound { + http.Error(w, fmt.Sprintf("Could not find movie with TMDB ID: %v", req.TmdbID), http.StatusNotFound) + return screenjournal.Review{}, false + } else if err != nil { + log.Printf("failed to get local media ID for movie with TMDB ID %v: %v", req.TmdbID, err) + http.Error(w, fmt.Sprintf("Failed to look up movie with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) + return screenjournal.Review{}, false + } + } else if req.MediaType == screenjournal.MediaTypeTvShow { + review.TvShow, err = s.tvShowfromTmdbID(s.store, req.TmdbID) + if err == store.ErrTvShowNotFound { + http.Error(w, fmt.Sprintf("Could not find tv show with TMDB ID: %v", req.TmdbID), http.StatusNotFound) + return screenjournal.Review{}, false + } else if err != nil { + log.Printf("failed to get local media ID for TV show with TMDB ID %v: %v", req.TmdbID, err) + http.Error(w, fmt.Sprintf("Failed to look up TV show with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) + return screenjournal.Review{}, false + } + } + + return review, true +} + +// publishedReviewRoute returns the URL of a published review, anchored to the +// review on its movie or TV show page. +func publishedReviewRoute(review screenjournal.Review) string { + if review.MediaType() == screenjournal.MediaTypeMovie { + return fmt.Sprintf("/movies/%d#review%d", review.Movie.ID.Int64(), review.ID.UInt64()) + } + return fmt.Sprintf("/tv-shows/%d?season=%d#review%d", review.TvShow.ID.Int64(), review.TvShowSeason.UInt8(), review.ID.UInt64()) +} + func (s Server) moviefromTmdbID(db sqlite.Store, tmdbID screenjournal.TmdbID) (screenjournal.Movie, error) { movie, err := db.ReadMovieByTmdbID(tmdbID) if err != nil && err != store.ErrMovieNotFound { diff --git a/handlers/reviews_drafts.go b/handlers/reviews_drafts.go new file mode 100644 index 00000000..6729c207 --- /dev/null +++ b/handlers/reviews_drafts.go @@ -0,0 +1,131 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/mtlynch/screenjournal/v2/screenjournal" + "github.com/mtlynch/screenjournal/v2/store" +) + +type reviewDraftResponse struct { + ReviewID screenjournal.ReviewID `json:"reviewId"` +} + +func (s Server) reviewsDraftsPost() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := parseReviewPostRequest(r, false) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) + return + } + saveDraftIntent := formBool(r, "save-draft") + + loggedInUsername := mustGetUsernameFromContext(r.Context()) + review, ok := s.reviewFromPostRequest(w, req, loggedInUsername, true) + if !ok { + return + } + + existingDraft, err := s.findExistingDraft(r, loggedInUsername, review) + if err != nil { + log.Printf("failed to find existing draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to save draft: %v", err), http.StatusInternalServerError) + return + } + statusCode := http.StatusCreated + if existingDraft != nil { + review.ID = existingDraft.ID + if err := s.store.UpdateReview(review); err != nil { + log.Printf("failed to update draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to save draft: %v", err), http.StatusInternalServerError) + return + } + statusCode = http.StatusOK + } else { + review.ID, err = s.store.InsertReview(review) + if err != nil { + log.Printf("failed to save draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to save draft: %v", err), http.StatusInternalServerError) + return + } + } + + if saveDraftIntent { + http.Redirect(w, r, "/reviews/drafts", http.StatusSeeOther) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(reviewDraftResponse{ReviewID: review.ID}); err != nil { + log.Printf("failed to encode draft response: %v", err) + } + } +} + +func (s Server) findExistingDraft(r *http.Request, owner screenjournal.Username, draft screenjournal.Review) (*screenjournal.Review, error) { + queryOptions := []store.ReadReviewsOption{ + store.FilterReviewsByUsername(owner), + store.FilterReviewsByDraftStatus(true), + } + if draft.MediaType() == screenjournal.MediaTypeMovie { + queryOptions = append(queryOptions, store.FilterReviewsByMovieID(draft.Movie.ID)) + } else { + queryOptions = append(queryOptions, store.FilterReviewsByTvShowID(draft.TvShow.ID)) + queryOptions = append(queryOptions, store.FilterReviewsByTvShowSeason(draft.TvShowSeason)) + } + + drafts, err := s.store.ReadReviews(queryOptions...) + if err != nil { + return nil, err + } + if len(drafts) == 0 { + return nil, nil + } + // A partial unique index (see migration 015) guarantees at most one draft + // per (owner, media, season), so the first match is the only match. This + // dedup is load-bearing: it's what keeps the editor's autosave from creating + // duplicate drafts when its initial "create" requests race. + return &drafts[0], nil +} + +func (s Server) reviewsDraftsPut() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := reviewIDFromRequestPath(r) + if err != nil { + http.Error(w, "Invalid review ID", http.StatusBadRequest) + return + } + + review, ok := s.loadOwnedReview(w, r, id) + if !ok { + return + } + + if !review.IsDraft { + http.Error(w, "Review already published", http.StatusBadRequest) + return + } + + parsedRequest, err := parseReviewPutRequest(r, false) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) + return + } + + review.Rating = parsedRequest.Rating + review.Blurb = parsedRequest.Blurb + review.Watched = parsedRequest.Watched + + if err := s.store.UpdateReview(review); err != nil { + log.Printf("failed to update draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to update draft: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/handlers/reviews_test.go b/handlers/reviews_test.go index 5857ba21..3da914fa 100644 --- a/handlers/reviews_test.go +++ b/handlers/reviews_test.go @@ -2,10 +2,12 @@ package handlers_test import ( "context" + "encoding/json" "fmt" "log" "net/http" "net/http/httptest" + "net/url" "reflect" "strings" "testing" @@ -21,6 +23,7 @@ import ( "github.com/mtlynch/screenjournal/v2/metadata" "github.com/mtlynch/screenjournal/v2/random" "github.com/mtlynch/screenjournal/v2/screenjournal" + "github.com/mtlynch/screenjournal/v2/store" "github.com/mtlynch/screenjournal/v2/store/test_sqlite" ) @@ -892,6 +895,388 @@ func TestReviewsPut(t *testing.T) { } } +func TestReviewsDraftsPost(t *testing.T) { + dataStore := test_sqlite.New() + + mockUser := screenjournal.User{ + Username: screenjournal.Username("userA"), + Email: screenjournal.Email("userA@example.com"), + PasswordHash: screenjournal.PasswordHash("dummy-password-hash"), + } + if err := dataStore.InsertUser(mockUser); err != nil { + t.Fatalf("failed to insert mock user: %+v: %v", mockUser, err) + } + + movie := screenjournal.Movie{ + TmdbID: screenjournal.TmdbID(38), + ImdbID: screenjournal.ImdbID("tt0338013"), + Title: screenjournal.MediaTitle("Eternal Sunshine of the Spotless Mind"), + ReleaseDate: screenjournal.ReleaseDate(mustParseDate("2004-03-19")), + } + if _, err := dataStore.InsertMovie(movie); err != nil { + t.Fatalf("failed to insert mock movie: %+v: %v", movie, err) + } + + announcer := mockAnnouncer{} + sessions := []mockSessionEntry{ + newMockSessionEntry("abc123", screenjournal.Username("userA")), + } + sessionManager := newMockSessionManager(sessions) + s := handlers.New(handlers.ServerParams{ + Authenticator: nilAuthenticator, + Announcer: &announcer, + SessionManager: &sessionManager, + Store: dataStore, + MetadataFinder: NewMockMetadataFinder(nil, nil), + }) + + payload := "media-type=movie&tmdb-id=38&rating=5&watch-date=2022-10-28&blurb=Draft%20thoughts" + req, err := http.NewRequest("POST", "/reviews/drafts", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + res := rec.Result() + + if got, want := res.StatusCode, http.StatusCreated; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + var response struct { + ReviewID screenjournal.ReviewID `json:"reviewId"` + } + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + review, err := dataStore.ReadReview(response.ReviewID) + if err != nil { + t.Fatalf("failed to read draft: %v", err) + } + + if got, want := review.Owner, screenjournal.Username("userA"); got != want { + t.Fatalf("owner=%v, want=%v", got, want) + } + if got, want := review.IsDraft, true; got != want { + t.Fatalf("isDraft=%v, want=%v", got, want) + } + if got, want := review.Blurb, screenjournal.Blurb("Draft thoughts"); got != want { + t.Fatalf("blurb=%v, want=%v", got, want) + } + if got, want := review.Rating, screenjournal.NewRating(5); !got.Equal(want) { + t.Fatalf("rating=%v, want=%v", got, want) + } + + if got, want := len(announcer.announcedReviews), 0; got != want { + t.Fatalf("announcedReviews=%d, want=%d", got, want) + } +} + +func TestReviewsDraftsPostReusesExistingDraft(t *testing.T) { + dataStore := test_sqlite.New() + + mockUser := screenjournal.User{ + Username: screenjournal.Username("userA"), + Email: screenjournal.Email("userA@example.com"), + PasswordHash: screenjournal.PasswordHash("dummy-password-hash"), + } + if err := dataStore.InsertUser(mockUser); err != nil { + t.Fatalf("failed to insert mock user: %+v: %v", mockUser, err) + } + + movie := screenjournal.Movie{ + TmdbID: screenjournal.TmdbID(38), + ImdbID: screenjournal.ImdbID("tt0338013"), + Title: screenjournal.MediaTitle("Eternal Sunshine of the Spotless Mind"), + ReleaseDate: screenjournal.ReleaseDate(mustParseDate("2004-03-19")), + } + if _, err := dataStore.InsertMovie(movie); err != nil { + t.Fatalf("failed to insert mock movie: %+v: %v", movie, err) + } + + announcer := mockAnnouncer{} + sessions := []mockSessionEntry{ + newMockSessionEntry("abc123", screenjournal.Username("userA")), + } + sessionManager := newMockSessionManager(sessions) + s := handlers.New(handlers.ServerParams{ + Authenticator: nilAuthenticator, + Announcer: &announcer, + SessionManager: &sessionManager, + Store: dataStore, + MetadataFinder: NewMockMetadataFinder(nil, nil), + }) + + firstPayload := "media-type=movie&tmdb-id=38&rating=5&watch-date=2022-10-28&blurb=Draft%20thoughts" + firstReq, err := http.NewRequest("POST", "/reviews/drafts", strings.NewReader(firstPayload)) + if err != nil { + t.Fatal(err) + } + firstReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + firstReq.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + firstRec := httptest.NewRecorder() + s.Router().ServeHTTP(firstRec, firstReq) + firstRes := firstRec.Result() + + if got, want := firstRes.StatusCode, http.StatusCreated; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + var firstResponse struct { + ReviewID screenjournal.ReviewID `json:"reviewId"` + } + if err := json.NewDecoder(firstRes.Body).Decode(&firstResponse); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + secondPayload := "media-type=movie&tmdb-id=38&rating=4&watch-date=2022-10-29&blurb=Updated%20draft" + secondReq, err := http.NewRequest("POST", "/reviews/drafts", strings.NewReader(secondPayload)) + if err != nil { + t.Fatal(err) + } + secondReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + secondReq.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + secondRec := httptest.NewRecorder() + s.Router().ServeHTTP(secondRec, secondReq) + secondRes := secondRec.Result() + + if got, want := secondRes.StatusCode, http.StatusOK; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + var secondResponse struct { + ReviewID screenjournal.ReviewID `json:"reviewId"` + } + if err := json.NewDecoder(secondRes.Body).Decode(&secondResponse); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if got, want := secondResponse.ReviewID, firstResponse.ReviewID; got != want { + t.Fatalf("reviewID=%v, want=%v", got, want) + } + + drafts, err := dataStore.ReadReviews( + store.FilterReviewsByUsername(screenjournal.Username("userA")), + store.FilterReviewsByMovieID(screenjournal.MovieID(1)), + store.FilterReviewsByDraftStatus(true), + ) + if err != nil { + t.Fatalf("failed to read drafts: %v", err) + } + if got, want := len(drafts), 1; got != want { + t.Fatalf("draftCount=%d, want=%d", got, want) + } + + if got, want := drafts[0].Blurb, screenjournal.Blurb("Updated draft"); got != want { + t.Fatalf("blurb=%v, want=%v", got, want) + } + if got, want := drafts[0].Rating, screenjournal.NewRating(4); !got.Equal(want) { + t.Fatalf("rating=%v, want=%v", got, want) + } +} + +func TestReviewsDraftsPut(t *testing.T) { + dataStore := test_sqlite.New() + + mockUser := screenjournal.User{ + Username: screenjournal.Username("userA"), + Email: screenjournal.Email("userA@example.com"), + PasswordHash: screenjournal.PasswordHash("dummy-password-hash"), + } + if err := dataStore.InsertUser(mockUser); err != nil { + t.Fatalf("failed to insert mock user: %+v: %v", mockUser, err) + } + + movie := screenjournal.Movie{ + TmdbID: screenjournal.TmdbID(38), + ImdbID: screenjournal.ImdbID("tt0338013"), + Title: screenjournal.MediaTitle("Eternal Sunshine of the Spotless Mind"), + ReleaseDate: screenjournal.ReleaseDate(mustParseDate("2004-03-19")), + } + movieID, err := dataStore.InsertMovie(movie) + if err != nil { + t.Fatalf("failed to insert mock movie: %+v: %v", movie, err) + } + + draft := screenjournal.Review{ + Owner: screenjournal.Username("userA"), + IsDraft: true, + Rating: screenjournal.NewRating(3), + Watched: mustParseWatchDate("2022-10-28"), + Blurb: screenjournal.Blurb("Initial thoughts"), + Movie: screenjournal.Movie{ + ID: movieID, + }, + } + draftID, err := dataStore.InsertReview(draft) + if err != nil { + t.Fatalf("failed to insert draft: %v", err) + } + + announcer := mockAnnouncer{} + sessions := []mockSessionEntry{ + newMockSessionEntry("abc123", screenjournal.Username("userA")), + } + sessionManager := newMockSessionManager(sessions) + s := handlers.New(handlers.ServerParams{ + Authenticator: nilAuthenticator, + Announcer: &announcer, + SessionManager: &sessionManager, + Store: dataStore, + MetadataFinder: NewMockMetadataFinder(nil, nil), + }) + + payload := "rating=4&watch-date=2022-10-30&blurb=Updated%20draft" + req, err := http.NewRequest("PUT", fmt.Sprintf("/reviews/drafts/%d", draftID), strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + res := rec.Result() + + if got, want := res.StatusCode, http.StatusNoContent; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + updated, err := dataStore.ReadReview(draftID) + if err != nil { + t.Fatalf("failed to read updated draft: %v", err) + } + + if got, want := updated.IsDraft, true; got != want { + t.Fatalf("isDraft=%v, want=%v", got, want) + } + if got, want := updated.Blurb, screenjournal.Blurb("Updated draft"); got != want { + t.Fatalf("blurb=%v, want=%v", got, want) + } + if got, want := updated.Rating, screenjournal.NewRating(4); !got.Equal(want) { + t.Fatalf("rating=%v, want=%v", got, want) + } +} + +func TestReviewsPutPublishesDraft(t *testing.T) { + dataStore := test_sqlite.New() + + mockUser := screenjournal.User{ + Username: screenjournal.Username("userA"), + Email: screenjournal.Email("userA@example.com"), + PasswordHash: screenjournal.PasswordHash("dummy-password-hash"), + } + if err := dataStore.InsertUser(mockUser); err != nil { + t.Fatalf("failed to insert mock user: %+v: %v", mockUser, err) + } + + movie := screenjournal.Movie{ + TmdbID: screenjournal.TmdbID(38), + ImdbID: screenjournal.ImdbID("tt0338013"), + Title: screenjournal.MediaTitle("Eternal Sunshine of the Spotless Mind"), + ReleaseDate: screenjournal.ReleaseDate(mustParseDate("2004-03-19")), + } + movieID, err := dataStore.InsertMovie(movie) + if err != nil { + t.Fatalf("failed to insert mock movie: %+v: %v", movie, err) + } + + draft := screenjournal.Review{ + Owner: screenjournal.Username("userA"), + IsDraft: true, + Rating: screenjournal.NewRating(3), + Watched: mustParseWatchDate("2022-10-28"), + Blurb: screenjournal.Blurb("Initial draft"), + Movie: screenjournal.Movie{ + ID: movieID, + }, + } + draftID, err := dataStore.InsertReview(draft) + if err != nil { + t.Fatalf("failed to insert draft: %v", err) + } + + announcer := mockAnnouncer{} + sessions := []mockSessionEntry{ + newMockSessionEntry("abc123", screenjournal.Username("userA")), + } + sessionManager := newMockSessionManager(sessions) + s := handlers.New(handlers.ServerParams{ + Authenticator: nilAuthenticator, + Announcer: &announcer, + SessionManager: &sessionManager, + Store: dataStore, + MetadataFinder: NewMockMetadataFinder(nil, nil), + }) + + payload := fmt.Sprintf( + "rating=5&watch-date=2022-10-30&blurb=%s", + url.QueryEscape("Final thoughts"), + ) + req, err := http.NewRequest("PUT", fmt.Sprintf("/reviews/%d", draftID), strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + res := rec.Result() + + if got, want := res.StatusCode, http.StatusSeeOther; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + published, err := dataStore.ReadReview(draftID) + if err != nil { + t.Fatalf("failed to read published review: %v", err) + } + + if got, want := published.IsDraft, false; got != want { + t.Fatalf("isDraft=%v, want=%v", got, want) + } + if got, want := published.Blurb, screenjournal.Blurb("Final thoughts"); got != want { + t.Fatalf("blurb=%v, want=%v", got, want) + } + if got, want := published.Rating, screenjournal.NewRating(5); !got.Equal(want) { + t.Fatalf("rating=%v, want=%v", got, want) + } + + rr, err := dataStore.ReadReviews() + if err != nil { + t.Fatalf("failed to read reviews: %v", err) + } + if got, want := len(rr), 1; got != want { + t.Fatalf("reviewCount=%d, want=%d", got, want) + } + + if got, want := len(announcer.announcedReviews), 1; got != want { + t.Fatalf("announcedReviews=%d, want=%d", got, want) + } +} + func clearUnpredictableReviewProperties(r *screenjournal.Review) { r.ID = screenjournal.ReviewID(0) r.Created = time.Time{} diff --git a/handlers/routes.go b/handlers/routes.go index a616b4b0..7cbba97f 100644 --- a/handlers/routes.go +++ b/handlers/routes.go @@ -58,6 +58,8 @@ func (s *Server) routes() { authenticatedRoutes.HandleFunc("/reviews", s.reviewsPost()).Methods(http.MethodPost) authenticatedRoutes.HandleFunc("/reviews/{reviewID}", s.reviewsPut()).Methods(http.MethodPut) authenticatedRoutes.HandleFunc("/reviews/{reviewID}", s.reviewsDelete()).Methods(http.MethodDelete) + authenticatedRoutes.HandleFunc("/reviews/drafts", s.reviewsDraftsPost()).Methods(http.MethodPost) + authenticatedRoutes.HandleFunc("/reviews/drafts/{reviewID}", s.reviewsDraftsPut()).Methods(http.MethodPut) authenticatedRoutes.HandleFunc("/reactions", s.reactionsPost()).Methods(http.MethodPost) authenticatedRoutes.HandleFunc("/reactions/{reactionID}", s.reactionsDelete()).Methods(http.MethodDelete) @@ -79,6 +81,7 @@ func (s *Server) routes() { authenticatedViews.HandleFunc("/movies/{movieID}", s.moviesReadGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/tv-shows/{tvShowID}", s.tvShowsReadGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/reviews", s.reviewsGet()).Methods(http.MethodGet) + authenticatedViews.HandleFunc("/reviews/drafts", s.reviewsDraftsGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/reviews/by/{username}", s.reviewsGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/reviews/new", s.reviewsNewTitleSearchGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/reviews/new/tv/pick-season", s.reviewsNewPickSeasonGet()).Methods(http.MethodGet) diff --git a/handlers/templates/pages/reviews-drafts.html b/handlers/templates/pages/reviews-drafts.html new file mode 100644 index 00000000..867d17b6 --- /dev/null +++ b/handlers/templates/pages/reviews-drafts.html @@ -0,0 +1,52 @@ +{{ define "title" }} + Drafts +{{ end }} + +{{ define "content" }} + {{ if not .Drafts }} +
You don't have any drafts yet.
+ {{ else }} ++ Last saved + {{ relativeCommentDate .Modified }} +
+ {{ with .Blurb | elideBlurb }} ++ {{ . }} +
+ {{ end }} + Continue ++
formatting options Season {{ .Review.TvShowSeason.UInt8 }}
{{ if $isEditing }}
@@ -225,7 +458,33 @@ Season {{ .Review.TvShowSeason.UInt8 }}