Support "since" parameter in "/last" query (#330)

* strip \n and spaces from retrieved title

* add "since" support to last storage and rest #329

* add info about since to readme
This commit is contained in:
Umputun
2019-05-16 13:58:37 -05:00
committed by GitHub
parent 61e594786c
commit 5218f3f37c
18 changed files with 138 additions and 54 deletions
+1 -1
View File
@@ -581,7 +581,7 @@ Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i
}{}
```
* `GET /api/v1/last/{max}?site=site-id` - get up to `{max}` last comments
* `GET /api/v1/last/{max}?site=site-id&since=ts-msec` - get up to `{max}` last comments, `since` (epoch time, milliseconds) is optional
* `GET /api/v1/id/{id}?site=site-id` - get comment by `comment id`
* `GET /api/v1/comments?site=site-id&user=id&limit=N` - get comment by `user id`, returns `response` object
```go
+1 -1
View File
@@ -26,7 +26,7 @@ func TestDisqus_Import(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, 4, size)
last, err := dataStore.Last("test", 10)
last, err := dataStore.Last("test", 10, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 4, len(last), "4 comments imported")
+4 -3
View File
@@ -4,6 +4,7 @@ import (
"io/ioutil"
"os"
"testing"
"time"
bolt "github.com/coreos/bbolt"
"github.com/stretchr/testify/assert"
@@ -35,7 +36,7 @@ func TestMigrator_ImportDisqus(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, 4, size)
last, err := dataStore.Last("test", 10)
last, err := dataStore.Last("test", 10, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 4, len(last), "4 comments imported")
}
@@ -61,7 +62,7 @@ func TestMigrator_ImportWordPress(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, 3, size)
last, err := dataStore.Last("test", 10)
last, err := dataStore.Last("test", 10, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 3, len(last), "3 comments imported")
}
@@ -91,7 +92,7 @@ func TestMigrator_ImportNative(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, 2, size)
last, err := dataStore.Last("radio-t", 10)
last, err := dataStore.Last("radio-t", 10, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 2, len(last), "2 comments imported")
}
+1 -1
View File
@@ -82,7 +82,7 @@ func TestNative_Import(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, 2, size)
comments, err := b.Last("radio-t", 10)
comments, err := b.Last("radio-t", 10, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 2, len(comments))
assert.Equal(t, "f863bd79-fec6-4a75-b308-61fe5dd02aa1", comments[0].ID)
+1 -1
View File
@@ -27,7 +27,7 @@ func TestWordPress_Import(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, 3, size)
last, err := dataStore.Last(siteID, 10)
last, err := dataStore.Last(siteID, 10, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 3, len(last), "3 comments imported")
+15 -2
View File
@@ -9,6 +9,7 @@ import (
"path"
"strconv"
"strings"
"time"
"github.com/go-chi/chi"
"github.com/go-chi/render"
@@ -117,7 +118,8 @@ func (s *Rest) infoCtrl(w http.ResponseWriter, r *http.Request) {
}
}
// GET /last/{limit}?site=siteID - last comments for the siteID, across all posts, sorted by time
// GET /last/{limit}?site=siteID&since=unix_ts_msec - last comments for the siteID, across all posts, sorted by time, optionally
// limited with "since" param
func (s *Rest) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site")
log.Printf("[DEBUG] get last comments for %s", siteID)
@@ -127,9 +129,20 @@ func (s *Rest) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
limit = 0
}
sinceTime := time.Time{}
since := r.URL.Query().Get("since")
if since != "" {
unixTS, err := strconv.ParseInt(since, 10, 64)
if err != nil {
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't translate since parameter", rest.ErrDecode)
return
}
sinceTime = time.Unix(unixTS/1000, 1000000*(unixTS%1000)) // since param in msec timestamp
}
key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(lastCommentsScope)
data, err := s.Cache.Get(key, func() ([]byte, error) {
comments, e := s.DataService.Last(siteID, limit)
comments, e := s.DataService.Last(siteID, limit, sinceTime)
if e != nil {
return nil, e
}
+20
View File
@@ -209,8 +209,11 @@ func TestRest_Last(t *testing.T) {
Locator: store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah2"}}
// add 3 comments
ts1 := time.Now().UnixNano() / 1000000
addComment(t, c1, ts)
id1 := addComment(t, c1, ts)
time.Sleep(10 * time.Millisecond)
ts2 := time.Now().UnixNano() / 1000000
id2 := addComment(t, c2, ts)
res, code = get(t, ts.URL+"/api/v1/last/2?site=radio-t")
@@ -222,6 +225,23 @@ func TestRest_Last(t *testing.T) {
assert.Equal(t, id1, comments[1].ID)
assert.Equal(t, id2, comments[0].ID)
res, code = get(t, fmt.Sprintf("%s/api/v1/last/2?site=radio-t&since=%d", ts.URL, ts1))
assert.Equal(t, 200, code)
comments = []store.Comment{}
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.Equal(t, 2, len(comments), "should have 2 comments")
assert.Equal(t, id1, comments[1].ID)
assert.Equal(t, id2, comments[0].ID)
res, code = get(t, fmt.Sprintf("%s/api/v1/last/2?site=radio-t&since=%d", ts.URL, ts2))
assert.Equal(t, 200, code)
comments = []store.Comment{}
err = json.Unmarshal([]byte(res), &comments)
assert.Nil(t, err)
assert.Equal(t, 1, len(comments), "should have 1 comments")
assert.Equal(t, id2, comments[0].ID)
res, code = get(t, ts.URL+"/api/v1/last/5?site=radio-t")
assert.Equal(t, 200, code)
err = json.Unmarshal([]byte(res), &comments)
+2 -2
View File
@@ -60,7 +60,7 @@ func (s *Rest) rssSiteCommentsCtrl(w http.ResponseWriter, r *http.Request) {
key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(siteID, lastCommentsScope)
data, err := s.Cache.Get(key, func() ([]byte, error) {
comments, e := s.DataService.Last(siteID, maxRssItems)
comments, e := s.DataService.Last(siteID, maxRssItems, time.Time{})
if e != nil {
return nil, e
}
@@ -94,7 +94,7 @@ func (s *Rest) rssRepliesCtrl(w http.ResponseWriter, r *http.Request) {
userName := ""
key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(siteID, lastCommentsScope)
data, err := s.Cache.Get(key, func() (res []byte, e error) {
comments, e := s.DataService.Last(siteID, maxLastCommentsReply)
comments, e := s.DataService.Last(siteID, maxLastCommentsReply, time.Time{})
if e != nil {
return nil, errors.Wrap(e, "can't get last comments")
}
+11 -1
View File
@@ -1,6 +1,7 @@
package engine
import (
"bytes"
"encoding/json"
"fmt"
"strings"
@@ -169,7 +170,7 @@ func (b *BoltDB) Find(locator store.Locator, sortFld string) (comments []store.C
}
// Last returns up to max last comments for given siteID
func (b *BoltDB) Last(siteID string, max int) (comments []store.Comment, err error) {
func (b *BoltDB) Last(siteID string, max int, since time.Time) (comments []store.Comment, err error) {
comments = []store.Comment{}
@@ -185,7 +186,16 @@ func (b *BoltDB) Last(siteID string, max int) (comments []store.Comment, err err
err = bdb.View(func(tx *bolt.Tx) error {
lastBkt := tx.Bucket([]byte(lastBucketName))
c := lastBkt.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
if !since.IsZero() {
// stop if reached "since" ts
tsSince := []byte(since.Format(tsNano))
if bytes.Compare(k, tsSince) <= 0 {
break
}
}
url, commentID, e := b.parseRef(v)
if e != nil {
return e
+25 -3
View File
@@ -111,20 +111,42 @@ func TestBoltDB_Last(t *testing.T) {
var b, teardown = prep(t)
defer teardown()
res, err := b.Last("radio-t", 0)
res, err := b.Last("radio-t", 0, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 2, len(res))
assert.Equal(t, "some text2", res[0].Text)
res, err = b.Last("radio-t", 1)
res, err = b.Last("radio-t", 1, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 1, len(res))
assert.Equal(t, "some text2", res[0].Text)
_, err = b.Last("bad", 0)
_, err = b.Last("bad", 0, time.Time{})
assert.EqualError(t, err, `site "bad" not found`)
}
func TestBoltDB_LastSince(t *testing.T) {
var b, teardown = prep(t)
defer teardown()
ts := time.Date(2017, 12, 20, 15, 18, 21, 0, time.Local)
res, err := b.Last("radio-t", 0, ts)
assert.Nil(t, err)
assert.Equal(t, 2, len(res))
assert.Equal(t, "some text2", res[0].Text)
ts = time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local)
res, err = b.Last("radio-t", 0, ts)
assert.Nil(t, err)
assert.Equal(t, 1, len(res))
assert.Equal(t, "some text2", res[0].Text)
ts = time.Date(2017, 12, 20, 16, 18, 22, 0, time.Local)
res, err = b.Last("radio-t", 0, ts)
assert.Nil(t, err)
assert.Equal(t, 0, len(res))
}
func TestBoltDB_Count(t *testing.T) {
var b, teardown = prep(t)
defer teardown()
+3 -3
View File
@@ -37,7 +37,7 @@ func TestBoltAdmin_Delete(t *testing.T) {
assert.Equal(t, "some text2", res[1].Text)
assert.False(t, res[1].Deleted)
comments, err := b.Last("radio-t", 10)
comments, err := b.Last("radio-t", 10, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 1, len(comments), "1 in last, 1 removed")
@@ -91,7 +91,7 @@ func TestBoltAdmin_DeleteAll(t *testing.T) {
err = b.DeleteAll("radio-t")
assert.Nil(t, err)
comments, err := b.Last("radio-t", 10)
comments, err := b.Last("radio-t", 10, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 0, len(comments), "nothing left")
@@ -125,7 +125,7 @@ func TestBoltAdmin_DeleteUser(t *testing.T) {
_, err = b.User("radio-t", "user1", 5, 0)
assert.EqualError(t, err, "no comments for user user1 in store")
comments, err := b.Last("radio-t", 10)
comments, err := b.Last("radio-t", 10, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 0, len(comments), "nothing left")
+11 -11
View File
@@ -28,17 +28,17 @@ type UserRequest struct {
// Accessor defines all usual access ops avail for regular user
type Accessor interface {
Create(comment store.Comment) (commentID string, err error) // create new comment, avoid dups by id
Get(locator store.Locator, commentID string) (store.Comment, error) // get comment by id
Put(locator store.Locator, comment store.Comment) error // update comment, mutable parts only
Find(locator store.Locator, sort string) ([]store.Comment, error) // find comments for locator
Last(siteID string, limit int) ([]store.Comment, error) // last comments for given site, sorted by time
User(siteID, userID string, limit, skip int) ([]store.Comment, error) // comments by user, sorted by time
UserCount(siteID, userID string) (int, error) // comments count by user
Count(locator store.Locator) (int, error) // number of comments for the post
List(siteID string, limit int, skip int) ([]store.PostInfo, error) // list of commented posts
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error) // get post info
Close() error // close/stop engine
Create(comment store.Comment) (commentID string, err error) // create new comment, avoid dups by id
Get(locator store.Locator, commentID string) (store.Comment, error) // get comment by id
Put(locator store.Locator, comment store.Comment) error // update comment, mutable parts only
Find(locator store.Locator, sort string) ([]store.Comment, error) // find comments for locator
Last(siteID string, limit int, since time.Time) ([]store.Comment, error) // last comments for given site, sorted by time
User(siteID, userID string, limit, skip int) ([]store.Comment, error) // comments by user, sorted by time
UserCount(siteID, userID string) (int, error) // comments count by user
Count(locator store.Locator) (int, error) // number of comments for the post
List(siteID string, limit int, skip int) ([]store.PostInfo, error) // list of commented posts
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error) // get post info
Close() error // close/stop engine
}
// Admin defines all store ops avail for admin only
+4 -1
View File
@@ -92,13 +92,16 @@ func (m *Mongo) Put(locator store.Locator, comment store.Comment) error {
}
// Last returns up to max last comments for given siteID
func (m *Mongo) Last(siteID string, max int) (comments []store.Comment, err error) {
func (m *Mongo) Last(siteID string, max int, since time.Time) (comments []store.Comment, err error) {
comments = []store.Comment{}
if max > lastLimit || max == 0 {
max = lastLimit
}
err = m.conn.WithCustomCollection(mongoPosts, func(coll *mgo.Collection) error {
query := bson.M{"locator.site": siteID, "delete": false}
if !since.IsZero() {
query["time"] = bson.M{"$gt": since}
}
return coll.Find(query).Sort("-time").Limit(max).All(&comments)
})
return comments, err
+15 -5
View File
@@ -90,12 +90,22 @@ func TestMongo_Last(t *testing.T) {
if skip {
return
}
res, err := m.Last("radio-t", 0)
res, err := m.Last("radio-t", 0, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 2, len(res))
assert.Equal(t, "some text2", res[0].Text)
res, err = m.Last("radio-t", 1)
res, err = m.Last("radio-t", 0, time.Date(2017, 12, 20, 15, 18, 21, 0, time.Local))
assert.Nil(t, err)
assert.Equal(t, 2, len(res))
assert.Equal(t, "some text2", res[0].Text)
res, err = m.Last("radio-t", 0, time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local))
assert.Nil(t, err)
assert.Equal(t, 1, len(res))
assert.Equal(t, "some text2", res[0].Text)
res, err = m.Last("radio-t", 1, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 1, len(res))
assert.Equal(t, "some text2", res[0].Text)
@@ -428,7 +438,7 @@ func TestMongo_Delete(t *testing.T) {
assert.Equal(t, "some text2", res[1].Text)
assert.False(t, res[1].Deleted)
comments, err := m.Last("radio-t", 10)
comments, err := m.Last("radio-t", 10, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 1, len(comments), "1 in last, 1 removed")
@@ -478,7 +488,7 @@ func TestMongo_DeleteAll(t *testing.T) {
err = m.DeleteAll("radio-t")
assert.Nil(t, err)
comments, err := m.Last("radio-t", 10)
comments, err := m.Last("radio-t", 10, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 0, len(comments), "nothing left")
@@ -510,7 +520,7 @@ func TestMongo_DeleteUser(t *testing.T) {
assert.Nil(t, err, "no comments for user user1 in store")
assert.Equal(t, 0, len(cc), "no comments for user user1 in store")
comments, err := m.Last("radio-t", 10)
comments, err := m.Last("radio-t", 10, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 0, len(comments), "nothing left")
}
+1 -1
View File
@@ -295,7 +295,7 @@ func (s *DataStore) HasReplies(comment store.Comment) bool {
return true
}
comments, err := s.Last(comment.Locator.SiteID, maxLastCommentsReply)
comments, err := s.Last(comment.Locator.SiteID, maxLastCommentsReply, time.Time{})
if err != nil {
log.Printf("[WARN] can't get last comments for reply check, %v", err)
return false
+15 -15
View File
@@ -97,7 +97,7 @@ func TestService_CreateFromPartialWithTitle(t *testing.T) {
res, err := b.Get(store.Locator{URL: "https://radio-t.com/p/2018/12/29/podcast-630/", SiteID: "radio-t"}, id)
assert.NoError(t, err)
t.Logf("%+v", res)
assert.Equal(t, "Радио-Т 630", res.PostTitle)
assert.Equal(t, "Радио-Т 630 — Радио-Т Подкаст", res.PostTitle)
comment.PostTitle = "post blah"
id, err = b.Create(comment)
@@ -174,7 +174,7 @@ func TestService_Vote(t *testing.T) {
_, err := b.Create(comment)
assert.NoError(t, err)
res, err := b.Last("radio-t", 0)
res, err := b.Last("radio-t", 0, time.Time{})
t.Logf("%+v", res[0])
assert.Nil(t, err)
assert.Equal(t, 3, len(res))
@@ -195,7 +195,7 @@ func TestService_Vote(t *testing.T) {
assert.NotNil(t, err, "double-voting rejected")
assert.True(t, strings.HasPrefix(err.Error(), "user user1 already voted"))
res, err = b.Last("radio-t", 0)
res, err = b.Last("radio-t", 0, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 3, len(res))
assert.Equal(t, 1, res[0].Score)
@@ -204,7 +204,7 @@ func TestService_Vote(t *testing.T) {
_, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", false)
assert.Nil(t, err, "vote reset")
res, err = b.Last("radio-t", 0)
res, err = b.Last("radio-t", 0, time.Time{})
assert.Nil(t, err)
assert.Equal(t, 3, len(res))
assert.Equal(t, 0, res[0].Score)
@@ -250,7 +250,7 @@ func TestService_VoteAggressive(t *testing.T) {
_, err := b.Create(comment)
assert.NoError(t, err)
res, err := b.Last("radio-t", 0)
res, err := b.Last("radio-t", 0, time.Time{})
require.Nil(t, err)
t.Logf("%+v", res[0])
assert.Equal(t, 3, len(res))
@@ -271,7 +271,7 @@ func TestService_VoteAggressive(t *testing.T) {
}()
}
wg.Wait()
res, err = b.Last("radio-t", 0)
res, err = b.Last("radio-t", 0, time.Time{})
require.NoError(t, err)
t.Logf("%+v", res[0])
@@ -290,7 +290,7 @@ func TestService_VoteAggressive(t *testing.T) {
}()
}
wg.Wait()
res, err = b.Last("radio-t", 0)
res, err = b.Last("radio-t", 0, time.Time{})
require.NoError(t, err)
assert.Equal(t, 3, len(res))
t.Logf("%+v %d", res[0], res[0].Score)
@@ -309,7 +309,7 @@ func TestService_VoteConcurrent(t *testing.T) {
}
_, err := b.Create(comment)
assert.NoError(t, err)
res, err := b.Last("radio-t", 0)
res, err := b.Last("radio-t", 0, time.Time{})
require.Nil(t, err)
// concurrent vote +1 as multiple users for the same comment
@@ -324,7 +324,7 @@ func TestService_VoteConcurrent(t *testing.T) {
}()
}
wg.Wait()
res, err = b.Last("radio-t", 0)
res, err = b.Last("radio-t", 0, time.Time{})
require.NoError(t, err)
assert.Equal(t, 100, res[0].Score, "should have 100 score")
assert.Equal(t, 100, len(res[0].Votes), "should have 100 votes")
@@ -370,7 +370,7 @@ func TestService_VoteControversy(t *testing.T) {
assert.InDelta(t, 1.73, c.Controversy, 0.01)
// check if stored
res, err := b.Last("radio-t", 0)
res, err := b.Last("radio-t", 0, time.Time{})
require.NoError(t, err)
assert.Equal(t, 1, res[0].Score, "should have 1 score")
assert.InDelta(t, 1.73, res[0].Controversy, 0.01)
@@ -404,7 +404,7 @@ func TestService_Pin(t *testing.T) {
defer teardown(t)
b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")}
res, err := b.Last("radio-t", 0)
res, err := b.Last("radio-t", 0, time.Time{})
t.Logf("%+v", res[0])
assert.Nil(t, err)
assert.Equal(t, 2, len(res))
@@ -428,7 +428,7 @@ func TestService_EditComment(t *testing.T) {
defer teardown(t)
b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")}
res, err := b.Last("radio-t", 0)
res, err := b.Last("radio-t", 0, time.Time{})
t.Logf("%+v", res[0])
assert.Nil(t, err)
assert.Equal(t, 2, len(res))
@@ -455,7 +455,7 @@ func TestService_DeleteComment(t *testing.T) {
defer teardown(t)
b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")}
res, err := b.Last("radio-t", 0)
res, err := b.Last("radio-t", 0, time.Time{})
t.Logf("%+v", res[0])
assert.Nil(t, err)
assert.Equal(t, 2, len(res))
@@ -474,7 +474,7 @@ func TestService_EditCommentDurationFailed(t *testing.T) {
defer teardown(t)
b := DataStore{Interface: prepStoreEngine(t), EditDuration: 100 * time.Millisecond, AdminStore: admin.NewStaticKeyStore("secret 123")}
res, err := b.Last("radio-t", 0)
res, err := b.Last("radio-t", 0, time.Time{})
t.Logf("%+v", res[0])
assert.Nil(t, err)
assert.Equal(t, 2, len(res))
@@ -491,7 +491,7 @@ func TestService_EditCommentReplyFailed(t *testing.T) {
defer teardown(t)
b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")}
res, err := b.Last("radio-t", 0)
res, err := b.Last("radio-t", 0, time.Time{})
t.Logf("%+v", res[1])
assert.Nil(t, err)
assert.Equal(t, 2, len(res))
+5 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"io"
"net/http"
"strings"
"time"
"github.com/go-pkgz/lcw"
@@ -85,7 +86,10 @@ func (t *TitleExtractor) isTitleElement(n *html.Node) bool {
func (t *TitleExtractor) traverse(n *html.Node) (string, bool) {
if t.isTitleElement(n) {
return n.FirstChild.Data, true
title := n.FirstChild.Data
title = strings.Replace(title, "\n", "", -1)
title = strings.TrimSpace(title)
return title, true
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
+3 -2
View File
@@ -24,7 +24,8 @@ func TestTitle_GetTitle(t *testing.T) {
title string
}{
{`<html><title>blah 123</title><body> 2222</body></html>`, true, "blah 123"},
{`<html><title>blah 123 `, true, "blah 123 "},
{`<html><title>blah 123 `, true, "blah 123"},
{"<html><title>\n\n blah 123 \n ", true, "blah 123"},
{`<html><body> 2222</body></html>`, false, ""},
}
@@ -44,7 +45,7 @@ func TestTitle_Get(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.String() == "/good" {
atomic.AddInt32(&hits, 1)
_, err := w.Write([]byte("<html><title>blah 123</title><body> 2222</body></html>"))
_, err := w.Write([]byte("<html><title>\n\n blah 123\n</title><body> 2222</body></html>"))
assert.NoError(t, err)
return
}