packages feed

espial-0.0.41: test/JsonWireFormatSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoImplicitPrelude #-}

-- | Pins the exact JSON wire format sent to / read from
-- frontend/src/types.ts.
module JsonWireFormatSpec (spec) where

import Data.Aeson qualified as A
import Database.Persist.Sql (toSqlKey)
import Handler.Notes (NoteForm (..))
import Model.Custom (BmSlug (..), NtSlug (..), PasswordHash (..))
import Model.Form
import TestImport
import Yesod.Form.Fields (Textarea (..))

t0 :: UTCTime
t0 = UTCTime (fromGregorian 2024 1 1) 0

t1 :: UTCTime
t1 = UTCTime (fromGregorian 2024 1 2) 0

shouldEncodeTo :: (A.ToJSON a) => a -> ByteString -> Expectation
shouldEncodeTo actual expectedJson =
  A.toJSON actual `shouldBe` fromMaybe (error "invalid expected JSON") (A.decodeStrict' expectedJson)

shouldDecodeTo :: (A.FromJSON a, Eq a, Show a) => ByteString -> a -> Expectation
shouldDecodeTo json expected = A.decodeStrict' json `shouldBe` Just expected

spec :: Spec
spec = describe "JSON wire format" $ do
  describe "BookmarkForm (app.dat.bmark / app.dat.bmarks)" $ do
    it "encodes every field, including archive fields, for an owned bookmark"
      $ bookmarkFormFixture
      `shouldEncodeTo` "{\"url\":\"https://example.com\",\"title\":\"Example\",\"description\":\"An extended description\",\"tags\":\"foo bar\",\"private\":false,\"toread\":true,\"bid\":1,\"slug\":\"abc123\",\"selected\":true,\"time\":\"2024-01-01T00:00:00Z\",\"archiveUrl\":\"https://archive.example.com/x\",\"archiveRequested\":true}"

    it "encodes unset optional fields as null, and omits archive fields entirely when absent"
      $ mkNewBookmarkForm False testUser "https://x.com" Nothing Nothing Nothing Nothing Nothing
      `shouldEncodeTo` "{\"url\":\"https://x.com\",\"title\":null,\"description\":null,\"tags\":null,\"private\":null,\"toread\":null,\"bid\":null,\"slug\":null,\"selected\":null,\"time\":null}"

    it "decodes a frontend-submitted bookmark form"
      $ "{\"url\":\"https://example.com\",\"title\":\"Example\",\"description\":\"An extended description\",\"tags\":\"foo bar\",\"private\":false,\"toread\":true,\"bid\":1,\"slug\":\"abc123\",\"selected\":true,\"time\":\"2024-01-01T00:00:00Z\",\"archiveUrl\":\"https://archive.example.com/x\",\"archiveRequested\":true}"
      `shouldDecodeTo` bookmarkFormFixture

  describe "Entity Note (app.dat.note / app.dat.notes)"
    $ it "encodes id, userId, and every Note field"
    $ noteFixture
    `shouldEncodeTo` "{\"id\":42,\"userId\":7,\"slug\":\"abcdefghij\",\"length\":4,\"title\":\"Title\",\"text\":\"Body text\",\"isMarkdown\":true,\"shared\":false,\"created\":\"2024-01-01T00:00:00Z\",\"updated\":\"2024-01-02T00:00:00Z\"}"

  describe "NoteForm (postAddNoteR request body)"
    $ it "decodes a frontend-submitted note form"
    $ "{\"id\":42,\"slug\":\"abcdefghij\",\"title\":\"Title\",\"text\":\"Body text\",\"isMarkdown\":true,\"shared\":false,\"created\":\"2024-01-01T00:00:00Z\",\"updated\":\"2024-01-02T00:00:00Z\"}"
    `shouldDecodeTo` NoteForm
      { _id = Just 42,
        _slug = Just (NtSlug "abcdefghij"),
        _title = Just "Title",
        _text = Just (Textarea "Body text"),
        _isMarkdown = Just True,
        _shared = Just False,
        _created = Just (UTCTimeStr t0),
        _updated = Just (UTCTimeStr t1)
      }

  describe "AccountSettingsForm (app.dat.accountSettings)"
    $ it "strips the leading underscore from every field name"
    $ AccountSettingsForm
      { _privateDefault = True,
        _archiveDefault = False,
        _suggestTags = True,
        _suggestTagsUseReturnKey = False,
        _privacyLock = False,
        _publicTagCloud = True,
        _previewNotes = True,
        _archiveBackendEnabled = False,
        _hasApiKey = True,
        _language = Just I18nLangDe
      }
    `shouldEncodeTo` "{\"privateDefault\":true,\"archiveDefault\":false,\"suggestTags\":true,\"suggestTagsUseReturnKey\":false,\"privacyLock\":false,\"publicTagCloud\":true,\"previewNotes\":true,\"archiveBackendEnabled\":false,\"hasApiKey\":true,\"language\":\"de\"}"

  describe "SharedP (app.dat.sharedp)"
    $ it "encodes as bare lowercase strings"
    $ do
      SharedAll `shouldEncodeTo` "\"all\""
      SharedPublic `shouldEncodeTo` "\"public\""
      SharedPrivate `shouldEncodeTo` "\"private\""

  describe "FilterP (app.dat.filter)"
    $ it "encodes as a {tag, contents} discriminated union"
    $ do
      FilterAll `shouldEncodeTo` "{\"tag\":\"FilterAll\"}"
      FilterSingle (BmSlug "abc123") `shouldEncodeTo` "{\"tag\":\"FilterSingle\",\"contents\":\"abc123\"}"

  describe "TagCloudMode (app.tagCloudMode)"
    $ it "encodes each variant's mode/value/expanded/lowerBound shape"
    $ do
      TagCloudModeTop True `shouldEncodeTo` "{\"mode\":\"top\",\"expanded\":true}"
      TagCloudModeTopLowerBound False 20 `shouldEncodeTo` "{\"mode\":\"lowerBound\",\"value\":20,\"expanded\":false}"
      TagCloudModeRelated True ["foo", "bar"] `shouldEncodeTo` "{\"mode\":\"related\",\"value\":\"foo bar\",\"expanded\":true}"
      TagCloudModeRelatedLowerBound False ["foo"] 5 `shouldEncodeTo` "{\"mode\":\"relatedLowerBound\",\"value\":\"foo\",\"lowerBound\":5,\"expanded\":false}"
      TagCloudModeNone `shouldEncodeTo` "{\"mode\":\"none\",\"value\":null,\"expanded\":false}"

  describe "TagSuggestionRequest / TagSuggestionResponse (api/tagSuggestions)" $ do
    it "decodes a frontend-submitted request"
      $ "{\"query\":\"fo\",\"currentTags\":[\"bar\"]}"
      `shouldDecodeTo` TagSuggestionRequest {_query = "fo", _currentTags = Just ["bar"]}

    it "encodes the suggestion list"
      $ TagSuggestionResponse {_suggestions = [Suggestion "foo" 3, Suggestion "food" 1]}
      `shouldEncodeTo` "{\"suggestions\":[{\"term\":\"foo\",\"count\":3},{\"term\":\"food\",\"count\":1}]}"

  describe "BulkEditForm (api/bm/bulkEdit request body)" $ do
    it "decodes a page selection"
      $ "{\"selection\":\"page\",\"bids\":[1,2,3],\"action\":\"star\",\"addTags\":\"foo\",\"removeTags\":\"bar\",\"selectionCount\":3}"
      `shouldDecodeTo` BulkEditForm
        { _beSelection = BulkSelectionPage [1, 2, 3],
          _beAction = Just BulkActionStar,
          _beAddTags = "foo",
          _beRemoveTags = "bar",
          _beSelectionCount = 3
        }

    it "decodes an all-matching-query selection"
      $ "{\"selection\":\"all\",\"filter\":{\"tag\":\"FilterAll\"},\"sharedp\":\"all\",\"tags\":[\"x\"],\"query\":\"q\",\"action\":null,\"addTags\":\"\",\"removeTags\":\"\",\"selectionCount\":10}"
      `shouldDecodeTo` BulkEditForm
        { _beSelection = BulkSelectionAll FilterAll SharedAll ["x"] (Just "q"),
          _beAction = Nothing,
          _beAddTags = "",
          _beRemoveTags = "",
          _beSelectionCount = 10
        }

  describe "NoteBulkEditForm (api/note/bulkEdit request body)" $ do
    it "decodes a page selection"
      $ "{\"selection\":\"page\",\"nids\":[5,6],\"action\":\"markdown\",\"selectionCount\":2}"
      `shouldDecodeTo` NoteBulkEditForm
        { _nbeSelection = NoteBulkSelectionPage [5, 6],
          _nbeAction = NoteBulkActionMarkdown,
          _nbeSelectionCount = 2
        }

    it "decodes an all-matching-query selection"
      $ "{\"selection\":\"all\",\"sharedp\":\"public\",\"query\":null,\"action\":\"delete\",\"selectionCount\":4}"
      `shouldDecodeTo` NoteBulkEditForm
        { _nbeSelection = NoteBulkSelectionAll SharedPublic Nothing,
          _nbeAction = NoteBulkActionDelete,
          _nbeSelectionCount = 4
        }

bookmarkFormFixture :: BookmarkForm
bookmarkFormFixture =
  BookmarkForm
    { _url = "https://example.com",
      _title = Just "Example",
      _description = Just (Textarea "An extended description"),
      _tags = Just "foo bar",
      _private = Just False,
      _toread = Just True,
      _bid = Just 1,
      _slug = Just (BmSlug "abc123"),
      _selected = Just True,
      _time = Just (UTCTimeStr t0),
      _archiveUrl = Just "https://archive.example.com/x",
      _archiveRequested = Just True
    }

noteFixture :: Entity Note
noteFixture =
  Entity
    (toSqlKey 42)
    Note
      { noteUserId = toSqlKey 7,
        noteSlug = NtSlug "abcdefghij",
        noteLength = 4,
        noteTitle = "Title",
        noteText = "Body text",
        noteIsMarkdown = True,
        noteShared = False,
        noteCreated = t0,
        noteUpdated = t1
      }

testUser :: User
testUser = User "u" (PasswordHash "h") Nothing False False True True False False True Nothing