Implement MVC

This commit is contained in:
Sung Won Cho 2021-01-03 17:51:57 +11:00
commit cd5d094c25
146 changed files with 13596 additions and 2328 deletions

View file

@ -1,28 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package api
import (
"net/http"
)
func (a *API) checkHealth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}

View file

@ -1,46 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package api
import (
"net/http"
"testing"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/jinzhu/gorm"
)
func TestCheckHealth(t *testing.T) {
// Setup
server := MustNewServer(t, &app.App{
DB: &gorm.DB{},
Clock: clock.NewMock(),
})
defer server.Close()
// Execute
req := testutils.MakeReq(server.URL, "GET", "/health", "")
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "Status code mismtach")
}

View file

@ -18,68 +18,68 @@
package api
import (
"net/http"
"strings"
"github.com/dnote/dnote/pkg/server/database"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
func paginate(conn *gorm.DB, page int) *gorm.DB {
limit := 30
// Paginate
if page > 0 {
offset := limit * (page - 1)
conn = conn.Offset(offset)
}
conn = conn.Limit(limit)
return conn
}
func getBookIDs(books []database.Book) []int {
ret := []int{}
for _, book := range books {
ret = append(ret, book.ID)
}
return ret
}
func validatePassword(password string) error {
if len(password) < 8 {
return errors.New("Password should be longer than 8 characters")
}
return nil
}
func getClientType(r *http.Request) string {
origin := r.Header.Get("Origin")
if strings.HasPrefix(origin, "moz-extension://") {
return "firefox-extension"
}
if strings.HasPrefix(origin, "chrome-extension://") {
return "chrome-extension"
}
userAgent := r.Header.Get("User-Agent")
if strings.HasPrefix(userAgent, "Go-http-client") {
return "cli"
}
return "web"
}
// notSupported is the handler for the route that is no longer supported
func (a *API) notSupported(w http.ResponseWriter, r *http.Request) {
http.Error(w, "API version is not supported. Please upgrade your client.", http.StatusGone)
return
}
// import (
// "net/http"
// "strings"
//
// "github.com/dnote/dnote/pkg/server/database"
// "github.com/jinzhu/gorm"
// "github.com/pkg/errors"
// )
//
// func paginate(conn *gorm.DB, page int) *gorm.DB {
// limit := 30
//
// // Paginate
// if page > 0 {
// offset := limit * (page - 1)
// conn = conn.Offset(offset)
// }
//
// conn = conn.Limit(limit)
//
// return conn
// }
//
// func getBookIDs(books []database.Book) []int {
// ret := []int{}
//
// for _, book := range books {
// ret = append(ret, book.ID)
// }
//
// return ret
// }
//
// func validatePassword(password string) error {
// if len(password) < 8 {
// return errors.New("Password should be longer than 8 characters")
// }
//
// return nil
// }
//
// func getClientType(r *http.Request) string {
// origin := r.Header.Get("Origin")
//
// if strings.HasPrefix(origin, "moz-extension://") {
// return "firefox-extension"
// }
//
// if strings.HasPrefix(origin, "chrome-extension://") {
// return "chrome-extension"
// }
//
// userAgent := r.Header.Get("User-Agent")
// if strings.HasPrefix(userAgent, "Go-http-client") {
// return "cli"
// }
//
// return "web"
// }
//
// // notSupported is the handler for the route that is no longer supported
// func (a *API) notSupported(w http.ResponseWriter, r *http.Request) {
// http.Error(w, "API version is not supported. Please upgrade your client.", http.StatusGone)
// return
// }

View file

@ -1,35 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package api
import (
"os"
"testing"
"github.com/dnote/dnote/pkg/server/testutils"
)
func TestMain(m *testing.M) {
testutils.InitTestDB()
code := m.Run()
testutils.ClearData(testutils.DB)
os.Exit(code)
}

View file

@ -18,307 +18,307 @@
package api
import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/handlers"
"github.com/dnote/dnote/pkg/server/helpers"
"github.com/dnote/dnote/pkg/server/operations"
"github.com/dnote/dnote/pkg/server/presenters"
"github.com/gorilla/mux"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
type ftsParams struct {
HighlightAll bool
}
func getHeadlineOptions(params *ftsParams) string {
headlineOptions := []string{
"StartSel=<dnotehl>",
"StopSel=</dnotehl>",
"ShortWord=0",
}
if params != nil && params.HighlightAll {
headlineOptions = append(headlineOptions, "HighlightAll=true")
} else {
headlineOptions = append(headlineOptions, "MaxFragments=3, MaxWords=50, MinWords=10")
}
return strings.Join(headlineOptions, ",")
}
func selectFTSFields(conn *gorm.DB, search string, params *ftsParams) *gorm.DB {
headlineOpts := getHeadlineOptions(params)
return conn.Select(`
notes.id,
notes.uuid,
notes.created_at,
notes.updated_at,
notes.book_uuid,
notes.user_id,
notes.added_on,
notes.edited_on,
notes.usn,
notes.deleted,
notes.encrypted,
ts_headline('english_nostop', notes.body, plainto_tsquery('english_nostop', ?), ?) AS body
`, search, headlineOpts)
}
func respondWithNote(w http.ResponseWriter, note database.Note) {
presentedNote := presenters.PresentNote(note)
handlers.RespondJSON(w, http.StatusOK, presentedNote)
}
func parseSearchQuery(q url.Values) string {
searchStr := q.Get("q")
return escapeSearchQuery(searchStr)
}
func getNoteBaseQuery(db *gorm.DB, noteUUID string, search string) *gorm.DB {
var conn *gorm.DB
if search != "" {
conn = selectFTSFields(db, search, &ftsParams{HighlightAll: true})
} else {
conn = db
}
conn = conn.Where("notes.uuid = ? AND deleted = ?", noteUUID, false)
return conn
}
func (a *API) getNote(w http.ResponseWriter, r *http.Request) {
user, _, err := handlers.AuthWithSession(a.App.DB, r, nil)
if err != nil {
handlers.DoError(w, "authenticating", err, http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
noteUUID := vars["noteUUID"]
note, ok, err := operations.GetNote(a.App.DB, noteUUID, user)
if !ok {
handlers.RespondNotFound(w)
return
}
if err != nil {
handlers.DoError(w, "finding note", err, http.StatusInternalServerError)
return
}
respondWithNote(w, note)
}
/**** getNotesHandler */
// GetNotesResponse is a reponse by getNotesHandler
type GetNotesResponse struct {
Notes []presenters.Note `json:"notes"`
Total int `json:"total"`
}
type dateRange struct {
lower int64
upper int64
}
func (a *API) getNotes(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
query := r.URL.Query()
respondGetNotes(a.App.DB, user.ID, query, w)
}
func respondGetNotes(db *gorm.DB, userID int, query url.Values, w http.ResponseWriter) {
q, err := parseGetNotesQuery(query)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
conn := getNotesBaseQuery(db, userID, q)
var total int
if err := conn.Model(database.Note{}).Count(&total).Error; err != nil {
handlers.DoError(w, "counting total", err, http.StatusInternalServerError)
return
}
notes := []database.Note{}
if total != 0 {
conn = orderGetNotes(conn)
conn = database.PreloadNote(conn)
conn = paginate(conn, q.Page)
if err := conn.Find(&notes).Error; err != nil {
handlers.DoError(w, "finding notes", err, http.StatusInternalServerError)
return
}
}
response := GetNotesResponse{
Notes: presenters.PresentNotes(notes),
Total: total,
}
handlers.RespondJSON(w, http.StatusOK, response)
}
type getNotesQuery struct {
Year int
Month int
Page int
Books []string
Search string
Encrypted bool
}
func parseGetNotesQuery(q url.Values) (getNotesQuery, error) {
yearStr := q.Get("year")
monthStr := q.Get("month")
books := q["book"]
pageStr := q.Get("page")
encryptedStr := q.Get("encrypted")
fmt.Println("books", books)
var page int
if len(pageStr) > 0 {
p, err := strconv.Atoi(pageStr)
if err != nil {
return getNotesQuery{}, errors.Errorf("invalid page %s", pageStr)
}
if p < 1 {
return getNotesQuery{}, errors.Errorf("invalid page %s", pageStr)
}
page = p
} else {
page = 1
}
var year int
if len(yearStr) > 0 {
y, err := strconv.Atoi(yearStr)
if err != nil {
return getNotesQuery{}, errors.Errorf("invalid year %s", yearStr)
}
year = y
}
var month int
if len(monthStr) > 0 {
m, err := strconv.Atoi(monthStr)
if err != nil {
return getNotesQuery{}, errors.Errorf("invalid month %s", monthStr)
}
if m < 1 || m > 12 {
return getNotesQuery{}, errors.Errorf("invalid month %s", monthStr)
}
month = m
}
var encrypted bool
if strings.ToLower(encryptedStr) == "true" {
encrypted = true
} else {
encrypted = false
}
ret := getNotesQuery{
Year: year,
Month: month,
Page: page,
Search: parseSearchQuery(q),
Books: books,
Encrypted: encrypted,
}
return ret, nil
}
func getDateBounds(year, month int) (int64, int64) {
var yearUpperbound, monthUpperbound int
if month == 12 {
monthUpperbound = 1
yearUpperbound = year + 1
} else {
monthUpperbound = month + 1
yearUpperbound = year
}
lower := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC).UnixNano()
upper := time.Date(yearUpperbound, time.Month(monthUpperbound), 1, 0, 0, 0, 0, time.UTC).UnixNano()
return lower, upper
}
func getNotesBaseQuery(db *gorm.DB, userID int, q getNotesQuery) *gorm.DB {
conn := db.Where(
"notes.user_id = ? AND notes.deleted = ? AND notes.encrypted = ?",
userID, false, q.Encrypted,
)
if q.Search != "" {
conn = selectFTSFields(conn, q.Search, nil)
conn = conn.Where("tsv @@ plainto_tsquery('english_nostop', ?)", q.Search)
}
if len(q.Books) > 0 {
conn = conn.Joins("INNER JOIN books ON books.uuid = notes.book_uuid").
Where("books.label in (?)", q.Books)
}
if q.Year != 0 || q.Month != 0 {
dateLowerbound, dateUpperbound := getDateBounds(q.Year, q.Month)
conn = conn.Where("notes.added_on >= ? AND notes.added_on < ?", dateLowerbound, dateUpperbound)
}
return conn
}
func orderGetNotes(conn *gorm.DB) *gorm.DB {
return conn.Order("notes.updated_at DESC, notes.id DESC")
}
// escapeSearchQuery escapes the query for full text search
func escapeSearchQuery(searchQuery string) string {
return strings.Join(strings.Fields(searchQuery), "&")
}
func (a *API) legacyGetNotes(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
var notes []database.Note
if err := a.App.DB.Where("user_id = ? AND encrypted = true", user.ID).Find(&notes).Error; err != nil {
handlers.DoError(w, "finding notes", err, http.StatusInternalServerError)
return
}
presented := presenters.PresentNotes(notes)
handlers.RespondJSON(w, http.StatusOK, presented)
}
// import (
// "fmt"
// "net/http"
// "net/url"
// "strconv"
// "strings"
// "time"
//
// "github.com/dnote/dnote/pkg/server/database"
// "github.com/dnote/dnote/pkg/server/helpers"
// "github.com/dnote/dnote/pkg/server/middleware"
// "github.com/dnote/dnote/pkg/server/operations"
// "github.com/dnote/dnote/pkg/server/presenters"
// "github.com/gorilla/mux"
// "github.com/jinzhu/gorm"
// "github.com/pkg/errors"
// )
//
// type ftsParams struct {
// HighlightAll bool
// }
//
// func getHeadlineOptions(params *ftsParams) string {
// headlineOptions := []string{
// "StartSel=<dnotehl>",
// "StopSel=</dnotehl>",
// "ShortWord=0",
// }
//
// if params != nil && params.HighlightAll {
// headlineOptions = append(headlineOptions, "HighlightAll=true")
// } else {
// headlineOptions = append(headlineOptions, "MaxFragments=3, MaxWords=50, MinWords=10")
// }
//
// return strings.Join(headlineOptions, ",")
// }
//
// func selectFTSFields(conn *gorm.DB, search string, params *ftsParams) *gorm.DB {
// headlineOpts := getHeadlineOptions(params)
//
// return conn.Select(`
// notes.id,
// notes.uuid,
// notes.created_at,
// notes.updated_at,
// notes.book_uuid,
// notes.user_id,
// notes.added_on,
// notes.edited_on,
// notes.usn,
// notes.deleted,
// notes.encrypted,
// ts_headline('english_nostop', notes.body, plainto_tsquery('english_nostop', ?), ?) AS body
// `, search, headlineOpts)
// }
//
// func respondWithNote(w http.ResponseWriter, note database.Note) {
// presentedNote := presenters.PresentNote(note)
//
// middleware.RespondJSON(w, http.StatusOK, presentedNote)
// }
//
// func parseSearchQuery(q url.Values) string {
// searchStr := q.Get("q")
//
// return escapeSearchQuery(searchStr)
// }
//
// func getNoteBaseQuery(db *gorm.DB, noteUUID string, search string) *gorm.DB {
// var conn *gorm.DB
// if search != "" {
// conn = selectFTSFields(db, search, &ftsParams{HighlightAll: true})
// } else {
// conn = db
// }
//
// conn = conn.Where("notes.uuid = ? AND deleted = ?", noteUUID, false)
//
// return conn
// }
//
// func (a *API) getNote(w http.ResponseWriter, r *http.Request) {
// user, _, err := middleware.AuthWithSession(a.App.DB, r)
// if err != nil {
// middleware.DoError(w, "authenticating", err, http.StatusInternalServerError)
// return
// }
//
// vars := mux.Vars(r)
// noteUUID := vars["noteUUID"]
//
// note, ok, err := operations.GetNote(a.App.DB, noteUUID, &user)
// if !ok {
// middleware.RespondNotFound(w)
// return
// }
// if err != nil {
// middleware.DoError(w, "finding note", err, http.StatusInternalServerError)
// return
// }
//
// respondWithNote(w, note)
// }
//
// /**** getNotesHandler */
//
// // GetNotesResponse is a reponse by getNotesHandler
// type GetNotesResponse struct {
// Notes []presenters.Note `json:"notes"`
// Total int `json:"total"`
// }
//
// type dateRange struct {
// lower int64
// upper int64
// }
//
// func (a *API) getNotes(w http.ResponseWriter, r *http.Request) {
// user, ok := r.Context().Value(helpers.KeyUser).(database.User)
// if !ok {
// middleware.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
// return
// }
// query := r.URL.Query()
//
// respondGetNotes(a.App.DB, user.ID, query, w)
// }
//
// func respondGetNotes(db *gorm.DB, userID int, query url.Values, w http.ResponseWriter) {
// q, err := parseGetNotesQuery(query)
// if err != nil {
// http.Error(w, err.Error(), http.StatusBadRequest)
// return
// }
//
// conn := getNotesBaseQuery(db, userID, q)
//
// var total int
// if err := conn.Model(database.Note{}).Count(&total).Error; err != nil {
// middleware.DoError(w, "counting total", err, http.StatusInternalServerError)
// return
// }
//
// notes := []database.Note{}
// if total != 0 {
// conn = orderGetNotes(conn)
// conn = database.PreloadNote(conn)
// conn = paginate(conn, q.Page)
//
// if err := conn.Find(&notes).Error; err != nil {
// middleware.DoError(w, "finding notes", err, http.StatusInternalServerError)
// return
// }
// }
//
// response := GetNotesResponse{
// Notes: presenters.PresentNotes(notes),
// Total: total,
// }
// middleware.RespondJSON(w, http.StatusOK, response)
// }
//
// type getNotesQuery struct {
// Year int
// Month int
// Page int
// Books []string
// Search string
// Encrypted bool
// }
//
// func parseGetNotesQuery(q url.Values) (getNotesQuery, error) {
// yearStr := q.Get("year")
// monthStr := q.Get("month")
// books := q["book"]
// pageStr := q.Get("page")
// encryptedStr := q.Get("encrypted")
//
// fmt.Println("books", books)
//
// var page int
// if len(pageStr) > 0 {
// p, err := strconv.Atoi(pageStr)
// if err != nil {
// return getNotesQuery{}, errors.Errorf("invalid page %s", pageStr)
// }
// if p < 1 {
// return getNotesQuery{}, errors.Errorf("invalid page %s", pageStr)
// }
//
// page = p
// } else {
// page = 1
// }
//
// var year int
// if len(yearStr) > 0 {
// y, err := strconv.Atoi(yearStr)
// if err != nil {
// return getNotesQuery{}, errors.Errorf("invalid year %s", yearStr)
// }
//
// year = y
// }
//
// var month int
// if len(monthStr) > 0 {
// m, err := strconv.Atoi(monthStr)
// if err != nil {
// return getNotesQuery{}, errors.Errorf("invalid month %s", monthStr)
// }
// if m < 1 || m > 12 {
// return getNotesQuery{}, errors.Errorf("invalid month %s", monthStr)
// }
//
// month = m
// }
//
// var encrypted bool
// if strings.ToLower(encryptedStr) == "true" {
// encrypted = true
// } else {
// encrypted = false
// }
//
// ret := getNotesQuery{
// Year: year,
// Month: month,
// Page: page,
// Search: parseSearchQuery(q),
// Books: books,
// Encrypted: encrypted,
// }
//
// return ret, nil
// }
//
// func getDateBounds(year, month int) (int64, int64) {
// var yearUpperbound, monthUpperbound int
//
// if month == 12 {
// monthUpperbound = 1
// yearUpperbound = year + 1
// } else {
// monthUpperbound = month + 1
// yearUpperbound = year
// }
//
// lower := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC).UnixNano()
// upper := time.Date(yearUpperbound, time.Month(monthUpperbound), 1, 0, 0, 0, 0, time.UTC).UnixNano()
//
// return lower, upper
// }
//
// func getNotesBaseQuery(db *gorm.DB, userID int, q getNotesQuery) *gorm.DB {
// conn := db.Where(
// "notes.user_id = ? AND notes.deleted = ? AND notes.encrypted = ?",
// userID, false, q.Encrypted,
// )
//
// if q.Search != "" {
// conn = selectFTSFields(conn, q.Search, nil)
// conn = conn.Where("tsv @@ plainto_tsquery('english_nostop', ?)", q.Search)
// }
//
// if len(q.Books) > 0 {
// conn = conn.Joins("INNER JOIN books ON books.uuid = notes.book_uuid").
// Where("books.label in (?)", q.Books)
// }
//
// if q.Year != 0 || q.Month != 0 {
// dateLowerbound, dateUpperbound := getDateBounds(q.Year, q.Month)
// conn = conn.Where("notes.added_on >= ? AND notes.added_on < ?", dateLowerbound, dateUpperbound)
// }
//
// return conn
// }
//
// func orderGetNotes(conn *gorm.DB) *gorm.DB {
// return conn.Order("notes.updated_at DESC, notes.id DESC")
// }
//
// // escapeSearchQuery escapes the query for full text search
// func escapeSearchQuery(searchQuery string) string {
// return strings.Join(strings.Fields(searchQuery), "&")
// }
//
// func (a *API) legacyGetNotes(w http.ResponseWriter, r *http.Request) {
// user, ok := r.Context().Value(helpers.KeyUser).(database.User)
// if !ok {
// middleware.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
// return
// }
//
// var notes []database.Note
// if err := a.App.DB.Where("user_id = ? AND encrypted = true", user.ID).Find(&notes).Error; err != nil {
// middleware.DoError(w, "finding notes", err, http.StatusInternalServerError)
// return
// }
//
// presented := presenters.PresentNotes(notes)
// middleware.RespondJSON(w, http.StatusOK, presented)
// }

View file

@ -18,340 +18,340 @@
package api
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"testing"
"time"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/presenters"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/pkg/errors"
)
func getExpectedNotePayload(n database.Note, b database.Book, u database.User) presenters.Note {
return presenters.Note{
UUID: n.UUID,
CreatedAt: n.CreatedAt,
UpdatedAt: n.UpdatedAt,
Body: n.Body,
AddedOn: n.AddedOn,
Public: n.Public,
USN: n.USN,
Book: presenters.NoteBook{
UUID: b.UUID,
Label: b.Label,
},
User: presenters.NoteUser{
UUID: u.UUID,
},
}
}
func TestGetNotes(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
anotherUser := testutils.SetupUserData()
b1 := database.Book{
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
b2 := database.Book{
UserID: user.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
b3 := database.Book{
UserID: anotherUser.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
n1 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "n1 content",
USN: 11,
Deleted: false,
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
n2 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "n2 content",
USN: 14,
Deleted: false,
AddedOn: time.Date(2018, time.August, 11, 22, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
n3 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "n3 content",
USN: 17,
Deleted: false,
AddedOn: time.Date(2017, time.January, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
n4 := database.Note{
UserID: user.ID,
BookUUID: b2.UUID,
Body: "n4 content",
USN: 18,
Deleted: false,
AddedOn: time.Date(2018, time.September, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n4), "preparing n4")
n5 := database.Note{
UserID: anotherUser.ID,
BookUUID: b3.UUID,
Body: "n5 content",
USN: 19,
Deleted: false,
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n5), "preparing n5")
n6 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "",
USN: 11,
Deleted: true,
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
}
testutils.MustExec(t, testutils.DB.Save(&n6), "preparing n6")
// Execute
req := testutils.MakeReq(server.URL, "GET", "/notes?year=2018&month=8", "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload GetNotesResponse
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record, n1Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n2.UUID).First(&n2Record), "finding n2Record")
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1Record")
expected := GetNotesResponse{
Notes: []presenters.Note{
getExpectedNotePayload(n2Record, b1, user),
getExpectedNotePayload(n1Record, b1, user),
},
Total: 2,
}
assert.DeepEqual(t, payload, expected, "payload mismatch")
}
func TestGetNote(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
anotherUser := testutils.SetupUserData()
b1 := database.Book{
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
privateNote := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "privateNote content",
Public: false,
}
testutils.MustExec(t, testutils.DB.Save(&privateNote), "preparing privateNote")
publicNote := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "publicNote content",
Public: true,
}
testutils.MustExec(t, testutils.DB.Save(&publicNote), "preparing publicNote")
deletedNote := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Deleted: true,
}
testutils.MustExec(t, testutils.DB.Save(&deletedNote), "preparing publicNote")
t.Run("owner accessing private note", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", privateNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Note
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n1Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", privateNote.UUID).First(&n1Record), "finding n1Record")
expected := getExpectedNotePayload(n1Record, b1, user)
assert.DeepEqual(t, payload, expected, "payload mismatch")
})
t.Run("owner accessing public note", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", publicNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Note
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
expected := getExpectedNotePayload(n2Record, b1, user)
assert.DeepEqual(t, payload, expected, "payload mismatch")
})
t.Run("non-owner accessing public note", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", publicNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, anotherUser)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Note
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
expected := getExpectedNotePayload(n2Record, b1, user)
assert.DeepEqual(t, payload, expected, "payload mismatch")
})
t.Run("non-owner accessing private note", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", privateNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, anotherUser)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
})
t.Run("guest accessing public note", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", publicNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload presenters.Note
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var n2Record database.Note
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
expected := getExpectedNotePayload(n2Record, b1, user)
assert.DeepEqual(t, payload, expected, "payload mismatch")
})
t.Run("guest accessing private note", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", privateNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPDo(t, req)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
})
t.Run("nonexistent", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", "someRandomString")
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
})
t.Run("deleted", func(t *testing.T) {
// Execute
url := fmt.Sprintf("/notes/%s", deletedNote.UUID)
req := testutils.MakeReq(server.URL, "GET", url, "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Fatal(errors.Wrap(err, "reading body"))
}
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
})
}
// import (
// "encoding/json"
// "fmt"
// "io/ioutil"
// "net/http"
// "testing"
// "time"
//
// "github.com/dnote/dnote/pkg/assert"
// "github.com/dnote/dnote/pkg/clock"
// "github.com/dnote/dnote/pkg/server/app"
// "github.com/dnote/dnote/pkg/server/database"
// "github.com/dnote/dnote/pkg/server/presenters"
// "github.com/dnote/dnote/pkg/server/testutils"
// "github.com/pkg/errors"
// )
//
// func getExpectedNotePayload(n database.Note, b database.Book, u database.User) presenters.Note {
// return presenters.Note{
// UUID: n.UUID,
// CreatedAt: n.CreatedAt,
// UpdatedAt: n.UpdatedAt,
// Body: n.Body,
// AddedOn: n.AddedOn,
// Public: n.Public,
// USN: n.USN,
// Book: presenters.NoteBook{
// UUID: b.UUID,
// Label: b.Label,
// },
// User: presenters.NoteUser{
// UUID: u.UUID,
// },
// }
// }
//
// func TestGetNotes(t *testing.T) {
// defer testutils.ClearData(testutils.DB)
//
// // Setup
// server := MustNewServer(t, &app.App{
// Clock: clock.NewMock(),
// })
// defer server.Close()
//
// user := testutils.SetupUserData()
// anotherUser := testutils.SetupUserData()
//
// b1 := database.Book{
// UserID: user.ID,
// Label: "js",
// }
// testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
// b2 := database.Book{
// UserID: user.ID,
// Label: "css",
// }
// testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
// b3 := database.Book{
// UserID: anotherUser.ID,
// Label: "css",
// }
// testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
//
// n1 := database.Note{
// UserID: user.ID,
// BookUUID: b1.UUID,
// Body: "n1 content",
// USN: 11,
// Deleted: false,
// AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
// }
// testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
// n2 := database.Note{
// UserID: user.ID,
// BookUUID: b1.UUID,
// Body: "n2 content",
// USN: 14,
// Deleted: false,
// AddedOn: time.Date(2018, time.August, 11, 22, 0, 0, 0, time.UTC).UnixNano(),
// }
// testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
// n3 := database.Note{
// UserID: user.ID,
// BookUUID: b1.UUID,
// Body: "n3 content",
// USN: 17,
// Deleted: false,
// AddedOn: time.Date(2017, time.January, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
// }
// testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
// n4 := database.Note{
// UserID: user.ID,
// BookUUID: b2.UUID,
// Body: "n4 content",
// USN: 18,
// Deleted: false,
// AddedOn: time.Date(2018, time.September, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
// }
// testutils.MustExec(t, testutils.DB.Save(&n4), "preparing n4")
// n5 := database.Note{
// UserID: anotherUser.ID,
// BookUUID: b3.UUID,
// Body: "n5 content",
// USN: 19,
// Deleted: false,
// AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
// }
// testutils.MustExec(t, testutils.DB.Save(&n5), "preparing n5")
// n6 := database.Note{
// UserID: user.ID,
// BookUUID: b1.UUID,
// Body: "",
// USN: 11,
// Deleted: true,
// AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
// }
// testutils.MustExec(t, testutils.DB.Save(&n6), "preparing n6")
//
// // Execute
// req := testutils.MakeReq(server.URL, "GET", "/notes?year=2018&month=8", "")
// res := testutils.HTTPAuthDo(t, req, user)
//
// // Test
// assert.StatusCodeEquals(t, res, http.StatusOK, "")
//
// var payload GetNotesResponse
// if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
// t.Fatal(errors.Wrap(err, "decoding payload"))
// }
//
// var n2Record, n1Record database.Note
// testutils.MustExec(t, testutils.DB.Where("uuid = ?", n2.UUID).First(&n2Record), "finding n2Record")
// testutils.MustExec(t, testutils.DB.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1Record")
//
// expected := GetNotesResponse{
// Notes: []presenters.Note{
// getExpectedNotePayload(n2Record, b1, user),
// getExpectedNotePayload(n1Record, b1, user),
// },
// Total: 2,
// }
//
// assert.DeepEqual(t, payload, expected, "payload mismatch")
// }
//
// func TestGetNote(t *testing.T) {
// defer testutils.ClearData(testutils.DB)
//
// // Setup
// server := MustNewServer(t, &app.App{
// Clock: clock.NewMock(),
// })
// defer server.Close()
//
// user := testutils.SetupUserData()
// anotherUser := testutils.SetupUserData()
//
// b1 := database.Book{
// UserID: user.ID,
// Label: "js",
// }
// testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
//
// privateNote := database.Note{
// UserID: user.ID,
// BookUUID: b1.UUID,
// Body: "privateNote content",
// Public: false,
// }
// testutils.MustExec(t, testutils.DB.Save(&privateNote), "preparing privateNote")
// publicNote := database.Note{
// UserID: user.ID,
// BookUUID: b1.UUID,
// Body: "publicNote content",
// Public: true,
// }
// testutils.MustExec(t, testutils.DB.Save(&publicNote), "preparing publicNote")
// deletedNote := database.Note{
// UserID: user.ID,
// BookUUID: b1.UUID,
// Deleted: true,
// }
// testutils.MustExec(t, testutils.DB.Save(&deletedNote), "preparing publicNote")
//
// t.Run("owner accessing private note", func(t *testing.T) {
// // Execute
// url := fmt.Sprintf("/notes/%s", privateNote.UUID)
// req := testutils.MakeReq(server.URL, "GET", url, "")
// res := testutils.HTTPAuthDo(t, req, user)
//
// // Test
// assert.StatusCodeEquals(t, res, http.StatusOK, "")
//
// var payload presenters.Note
// if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
// t.Fatal(errors.Wrap(err, "decoding payload"))
// }
//
// var n1Record database.Note
// testutils.MustExec(t, testutils.DB.Where("uuid = ?", privateNote.UUID).First(&n1Record), "finding n1Record")
//
// expected := getExpectedNotePayload(n1Record, b1, user)
// assert.DeepEqual(t, payload, expected, "payload mismatch")
// })
//
// t.Run("owner accessing public note", func(t *testing.T) {
// // Execute
// url := fmt.Sprintf("/notes/%s", publicNote.UUID)
// req := testutils.MakeReq(server.URL, "GET", url, "")
// res := testutils.HTTPAuthDo(t, req, user)
//
// // Test
// assert.StatusCodeEquals(t, res, http.StatusOK, "")
//
// var payload presenters.Note
// if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
// t.Fatal(errors.Wrap(err, "decoding payload"))
// }
//
// var n2Record database.Note
// testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
//
// expected := getExpectedNotePayload(n2Record, b1, user)
// assert.DeepEqual(t, payload, expected, "payload mismatch")
// })
//
// t.Run("non-owner accessing public note", func(t *testing.T) {
// // Execute
// url := fmt.Sprintf("/notes/%s", publicNote.UUID)
// req := testutils.MakeReq(server.URL, "GET", url, "")
// res := testutils.HTTPAuthDo(t, req, anotherUser)
//
// // Test
// assert.StatusCodeEquals(t, res, http.StatusOK, "")
//
// var payload presenters.Note
// if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
// t.Fatal(errors.Wrap(err, "decoding payload"))
// }
//
// var n2Record database.Note
// testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
//
// expected := getExpectedNotePayload(n2Record, b1, user)
// assert.DeepEqual(t, payload, expected, "payload mismatch")
// })
//
// t.Run("non-owner accessing private note", func(t *testing.T) {
// // Execute
// url := fmt.Sprintf("/notes/%s", privateNote.UUID)
// req := testutils.MakeReq(server.URL, "GET", url, "")
// res := testutils.HTTPAuthDo(t, req, anotherUser)
//
// // Test
// assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
//
// body, err := ioutil.ReadAll(res.Body)
// if err != nil {
// t.Fatal(errors.Wrap(err, "reading body"))
// }
//
// assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
// })
//
// t.Run("guest accessing public note", func(t *testing.T) {
// // Execute
// url := fmt.Sprintf("/notes/%s", publicNote.UUID)
// req := testutils.MakeReq(server.URL, "GET", url, "")
// res := testutils.HTTPDo(t, req)
//
// // Test
// assert.StatusCodeEquals(t, res, http.StatusOK, "")
//
// var payload presenters.Note
// if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
// t.Fatal(errors.Wrap(err, "decoding payload"))
// }
//
// var n2Record database.Note
// testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
//
// expected := getExpectedNotePayload(n2Record, b1, user)
// assert.DeepEqual(t, payload, expected, "payload mismatch")
// })
//
// t.Run("guest accessing private note", func(t *testing.T) {
// // Execute
// url := fmt.Sprintf("/notes/%s", privateNote.UUID)
// req := testutils.MakeReq(server.URL, "GET", url, "")
// res := testutils.HTTPDo(t, req)
//
// // Test
// assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
//
// body, err := ioutil.ReadAll(res.Body)
// if err != nil {
// t.Fatal(errors.Wrap(err, "reading body"))
// }
//
// assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
// })
//
// t.Run("nonexistent", func(t *testing.T) {
// // Execute
// url := fmt.Sprintf("/notes/%s", "someRandomString")
// req := testutils.MakeReq(server.URL, "GET", url, "")
// res := testutils.HTTPAuthDo(t, req, user)
//
// // Test
// assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
//
// body, err := ioutil.ReadAll(res.Body)
// if err != nil {
// t.Fatal(errors.Wrap(err, "reading body"))
// }
//
// assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
// })
//
// t.Run("deleted", func(t *testing.T) {
// // Execute
// url := fmt.Sprintf("/notes/%s", deletedNote.UUID)
// req := testutils.MakeReq(server.URL, "GET", url, "")
// res := testutils.HTTPAuthDo(t, req, user)
//
// // Test
// assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
//
// body, err := ioutil.ReadAll(res.Body)
// if err != nil {
// t.Fatal(errors.Wrap(err, "reading body"))
// }
//
// assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
// })
// }

View file

@ -18,99 +18,99 @@
package api
import (
"net/http"
"os"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/handlers"
"github.com/gorilla/mux"
"github.com/pkg/errors"
)
// API is a web API configuration
type API struct {
App *app.App
}
// init sets up the application based on the configuration
func (a *API) init() error {
if err := a.App.Validate(); err != nil {
return errors.Wrap(err, "validating the app parameters")
}
return nil
}
func applyMiddleware(h http.HandlerFunc, rateLimit bool) http.Handler {
ret := h
ret = handlers.Logging(ret)
if rateLimit && os.Getenv("GO_ENV") != "TEST" {
ret = handlers.Limit(ret)
}
return ret
}
// NewRouter creates and returns a new router
func NewRouter(a *API) (*mux.Router, error) {
if err := a.init(); err != nil {
return nil, errors.Wrap(err, "initializing app")
}
proOnly := handlers.AuthParams{ProOnly: true}
app := a.App
var routes = []handlers.Route{
// internal
{Method: "GET", Pattern: "/health", HandlerFunc: a.checkHealth, RateLimit: false},
{Method: "GET", Pattern: "/me", HandlerFunc: handlers.Auth(app, a.getMe, nil), RateLimit: true},
{Method: "POST", Pattern: "/verification-token", HandlerFunc: handlers.Auth(app, a.createVerificationToken, nil), RateLimit: true},
{Method: "PATCH", Pattern: "/verify-email", HandlerFunc: a.verifyEmail, RateLimit: true},
{Method: "POST", Pattern: "/reset-token", HandlerFunc: a.createResetToken, RateLimit: true},
{Method: "PATCH", Pattern: "/reset-password", HandlerFunc: a.resetPassword, RateLimit: true},
{Method: "PATCH", Pattern: "/account/profile", HandlerFunc: handlers.Auth(app, a.updateProfile, nil), RateLimit: true},
{Method: "PATCH", Pattern: "/account/password", HandlerFunc: handlers.Auth(app, a.updatePassword, nil), RateLimit: true},
{Method: "GET", Pattern: "/account/email-preference", HandlerFunc: handlers.TokenAuth(app, a.getEmailPreference, database.TokenTypeEmailPreference, nil), RateLimit: true},
{Method: "PATCH", Pattern: "/account/email-preference", HandlerFunc: handlers.TokenAuth(app, a.updateEmailPreference, database.TokenTypeEmailPreference, nil), RateLimit: true},
{Method: "GET", Pattern: "/notes", HandlerFunc: handlers.Auth(app, a.getNotes, nil), RateLimit: false},
{Method: "GET", Pattern: "/notes/{noteUUID}", HandlerFunc: a.getNote, RateLimit: true},
{Method: "GET", Pattern: "/calendar", HandlerFunc: handlers.Auth(app, a.getCalendar, nil), RateLimit: true},
// v3
{Method: "GET", Pattern: "/v3/sync/fragment", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetSyncFragment, &proOnly)), RateLimit: false},
{Method: "GET", Pattern: "/v3/sync/state", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetSyncState, &proOnly)), RateLimit: false},
{Method: "OPTIONS", Pattern: "/v3/books", HandlerFunc: handlers.Cors(a.BooksOptions), RateLimit: true},
{Method: "GET", Pattern: "/v3/books", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetBooks, &proOnly)), RateLimit: true},
{Method: "GET", Pattern: "/v3/books/{bookUUID}", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetBook, &proOnly)), RateLimit: true},
{Method: "POST", Pattern: "/v3/books", HandlerFunc: handlers.Cors(handlers.Auth(app, a.CreateBook, &proOnly)), RateLimit: false},
{Method: "PATCH", Pattern: "/v3/books/{bookUUID}", HandlerFunc: handlers.Cors(handlers.Auth(app, a.UpdateBook, &proOnly)), RateLimit: false},
{Method: "DELETE", Pattern: "/v3/books/{bookUUID}", HandlerFunc: handlers.Cors(handlers.Auth(app, a.DeleteBook, &proOnly)), RateLimit: false},
{Method: "OPTIONS", Pattern: "/v3/notes", HandlerFunc: handlers.Cors(a.NotesOptions), RateLimit: true},
{Method: "POST", Pattern: "/v3/notes", HandlerFunc: handlers.Cors(handlers.Auth(app, a.CreateNote, &proOnly)), RateLimit: false},
{Method: "PATCH", Pattern: "/v3/notes/{noteUUID}", HandlerFunc: handlers.Auth(app, a.UpdateNote, &proOnly), RateLimit: false},
{Method: "DELETE", Pattern: "/v3/notes/{noteUUID}", HandlerFunc: handlers.Auth(app, a.DeleteNote, &proOnly), RateLimit: false},
{Method: "POST", Pattern: "/v3/signin", HandlerFunc: handlers.Cors(a.signin), RateLimit: true},
{Method: "OPTIONS", Pattern: "/v3/signout", HandlerFunc: handlers.Cors(a.signoutOptions), RateLimit: true},
{Method: "POST", Pattern: "/v3/signout", HandlerFunc: handlers.Cors(a.signout), RateLimit: true},
{Method: "POST", Pattern: "/v3/register", HandlerFunc: a.register, RateLimit: true},
}
router := mux.NewRouter().StrictSlash(true)
router.PathPrefix("/v1").Handler(applyMiddleware(handlers.NotSupported, true))
router.PathPrefix("/v2").Handler(applyMiddleware(handlers.NotSupported, true))
for _, route := range routes {
handler := route.HandlerFunc
router.
Methods(route.Method).
Path(route.Pattern).
Handler(applyMiddleware(handler, route.RateLimit))
}
return router, nil
}
// import (
// "net/http"
// "os"
//
// "github.com/dnote/dnote/pkg/server/app"
// "github.com/dnote/dnote/pkg/server/database"
// "github.com/dnote/dnote/pkg/server/middleware"
// "github.com/gorilla/mux"
// "github.com/pkg/errors"
// )
//
// // API is a web API configuration
// type API struct {
// App *app.App
// }
//
// // init sets up the application based on the configuration
// func (a *API) init() error {
// if err := a.App.Validate(); err != nil {
// return errors.Wrap(err, "validating the app parameters")
// }
//
// return nil
// }
//
// func applyMiddleware(h http.HandlerFunc, rateLimit bool) http.Handler {
// ret := h
// ret = middleware.Logging(ret)
//
// if rateLimit && os.Getenv("GO_ENV") != "TEST" {
// ret = middleware.Limit(ret)
// }
//
// return ret
// }
//
// // NewRouter creates and returns a new router
// func NewRouter(a *API) (*mux.Router, error) {
// if err := a.init(); err != nil {
// return nil, errors.Wrap(err, "initializing app")
// }
//
// proOnly := middleware.AuthParams{ProOnly: true}
// app := a.App
//
// var routes = []middleware.Route{
// // internal
// {Method: "GET", Pattern: "/health", HandlerFunc: a.checkHealth, RateLimit: false},
// {Method: "GET", Pattern: "/me", HandlerFunc: middleware.Auth(app, a.getMe, nil), RateLimit: true},
// {Method: "POST", Pattern: "/verification-token", HandlerFunc: middleware.Auth(app, a.createVerificationToken, nil), RateLimit: true},
// {Method: "PATCH", Pattern: "/verify-email", HandlerFunc: a.verifyEmail, RateLimit: true},
// {Method: "POST", Pattern: "/reset-token", HandlerFunc: a.createResetToken, RateLimit: true},
// {Method: "PATCH", Pattern: "/reset-password", HandlerFunc: a.resetPassword, RateLimit: true},
// {Method: "PATCH", Pattern: "/account/profile", HandlerFunc: middleware.Auth(app, a.updateProfile, nil), RateLimit: true},
// {Method: "PATCH", Pattern: "/account/password", HandlerFunc: middleware.Auth(app, a.updatePassword, nil), RateLimit: true},
// {Method: "GET", Pattern: "/account/email-preference", HandlerFunc: middleware.TokenAuth(app, a.getEmailPreference, database.TokenTypeEmailPreference, nil), RateLimit: true},
// {Method: "PATCH", Pattern: "/account/email-preference", HandlerFunc: middleware.TokenAuth(app, a.updateEmailPreference, database.TokenTypeEmailPreference, nil), RateLimit: true},
// {Method: "GET", Pattern: "/notes", HandlerFunc: middleware.Auth(app, a.getNotes, nil), RateLimit: false},
// {Method: "GET", Pattern: "/notes/{noteUUID}", HandlerFunc: a.getNote, RateLimit: true},
// {Method: "GET", Pattern: "/calendar", HandlerFunc: middleware.Auth(app, a.getCalendar, nil), RateLimit: true},
//
// // v3
// {Method: "GET", Pattern: "/v3/sync/fragment", HandlerFunc: middleware.Cors(middleware.Auth(app, a.GetSyncFragment, &proOnly)), RateLimit: false},
// {Method: "GET", Pattern: "/v3/sync/state", HandlerFunc: middleware.Cors(middleware.Auth(app, a.GetSyncState, &proOnly)), RateLimit: false},
// {Method: "OPTIONS", Pattern: "/v3/books", HandlerFunc: middleware.Cors(a.BooksOptions), RateLimit: true},
// {Method: "GET", Pattern: "/v3/books", HandlerFunc: middleware.Cors(middleware.Auth(app, a.GetBooks, &proOnly)), RateLimit: true},
// {Method: "GET", Pattern: "/v3/books/{bookUUID}", HandlerFunc: middleware.Cors(middleware.Auth(app, a.GetBook, &proOnly)), RateLimit: true},
// {Method: "POST", Pattern: "/v3/books", HandlerFunc: middleware.Cors(middleware.Auth(app, a.CreateBook, &proOnly)), RateLimit: false},
// {Method: "PATCH", Pattern: "/v3/books/{bookUUID}", HandlerFunc: middleware.Cors(middleware.Auth(app, a.UpdateBook, &proOnly)), RateLimit: false},
// {Method: "DELETE", Pattern: "/v3/books/{bookUUID}", HandlerFunc: middleware.Cors(middleware.Auth(app, a.DeleteBook, &proOnly)), RateLimit: false},
// {Method: "OPTIONS", Pattern: "/v3/notes", HandlerFunc: middleware.Cors(a.NotesOptions), RateLimit: true},
// {Method: "POST", Pattern: "/v3/notes", HandlerFunc: middleware.Cors(middleware.Auth(app, a.CreateNote, &proOnly)), RateLimit: false},
// {Method: "PATCH", Pattern: "/v3/notes/{noteUUID}", HandlerFunc: middleware.Auth(app, a.UpdateNote, &proOnly), RateLimit: false},
// {Method: "DELETE", Pattern: "/v3/notes/{noteUUID}", HandlerFunc: middleware.Auth(app, a.DeleteNote, &proOnly), RateLimit: false},
// {Method: "POST", Pattern: "/v3/signin", HandlerFunc: middleware.Cors(a.signin), RateLimit: true},
// {Method: "OPTIONS", Pattern: "/v3/signout", HandlerFunc: middleware.Cors(a.signoutOptions), RateLimit: true},
// {Method: "POST", Pattern: "/v3/signout", HandlerFunc: middleware.Cors(a.signout), RateLimit: true},
// {Method: "POST", Pattern: "/v3/register", HandlerFunc: a.register, RateLimit: true},
// }
//
// router := mux.NewRouter().StrictSlash(true)
//
// router.PathPrefix("/v1").Handler(applyMiddleware(middleware.NotSupported, true))
// router.PathPrefix("/v2").Handler(applyMiddleware(middleware.NotSupported, true))
//
// for _, route := range routes {
// handler := route.HandlerFunc
//
// router.
// Methods(route.Method).
// Path(route.Pattern).
// Handler(applyMiddleware(handler, route.RateLimit))
// }
//
// return router, nil
// }

View file

@ -18,31 +18,31 @@
package api
import (
"net/http/httptest"
"testing"
"github.com/dnote/dnote/pkg/server/app"
"github.com/pkg/errors"
)
// MustNewServer is a test utility function to initialize a new server
// with the given app paratmers
func MustNewServer(t *testing.T, appParams *app.App) *httptest.Server {
api := NewTestAPI(appParams)
r, err := NewRouter(&api)
if err != nil {
t.Fatal(errors.Wrap(err, "initializing server"))
}
server := httptest.NewServer(r)
return server
}
// NewTestAPI returns a new API for test
func NewTestAPI(appParams *app.App) API {
a := app.NewTest(appParams)
return API{App: &a}
}
// import (
// "net/http/httptest"
// "testing"
//
// "github.com/dnote/dnote/pkg/server/app"
// "github.com/pkg/errors"
// )
//
// // MustNewServer is a test utility function to initialize a new server
// // with the given app paratmers
// func MustNewServer(t *testing.T, appParams *app.App) *httptest.Server {
// api := NewTestAPI(appParams)
// r, err := NewRouter(&api)
// if err != nil {
// t.Fatal(errors.Wrap(err, "initializing server"))
// }
//
// server := httptest.NewServer(r)
//
// return server
// }
//
// // NewTestAPI returns a new API for test
// func NewTestAPI(appParams *app.App) API {
// a := app.NewTest(appParams)
//
// return API{App: &a}
// }

View file

@ -18,377 +18,377 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/handlers"
"github.com/dnote/dnote/pkg/server/helpers"
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/dnote/dnote/pkg/server/presenters"
"github.com/dnote/dnote/pkg/server/session"
"github.com/dnote/dnote/pkg/server/token"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
type updateProfilePayload struct {
Email string `json:"email"`
Password string `json:"password"`
}
// updateProfile updates user
func (a *API) updateProfile(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
var account database.Account
if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
handlers.DoError(w, "getting account", nil, http.StatusInternalServerError)
return
}
var params updateProfilePayload
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
http.Error(w, errors.Wrap(err, "invalid params").Error(), http.StatusBadRequest)
return
}
password := []byte(params.Password)
if err := bcrypt.CompareHashAndPassword([]byte(account.Password.String), password); err != nil {
log.WithFields(log.Fields{
"user_id": user.ID,
}).Warn("invalid email update attempt")
http.Error(w, "Wrong password", http.StatusUnauthorized)
return
}
// Validate
if len(params.Email) > 60 {
http.Error(w, "Email is too long", http.StatusBadRequest)
return
}
tx := a.App.DB.Begin()
if err := tx.Save(&user).Error; err != nil {
tx.Rollback()
handlers.DoError(w, "saving user", err, http.StatusInternalServerError)
return
}
// check if email was changed
if params.Email != account.Email.String {
account.EmailVerified = false
}
account.Email.String = params.Email
if err := tx.Save(&account).Error; err != nil {
tx.Rollback()
handlers.DoError(w, "saving account", err, http.StatusInternalServerError)
return
}
tx.Commit()
a.respondWithSession(a.App.DB, w, user.ID, http.StatusOK)
}
type updateEmailPayload struct {
NewEmail string `json:"new_email"`
NewCipherKeyEnc string `json:"new_cipher_key_enc"`
OldAuthKey string `json:"old_auth_key"`
NewAuthKey string `json:"new_auth_key"`
}
func respondWithCalendar(db *gorm.DB, w http.ResponseWriter, userID int) {
rows, err := db.Table("notes").Select("COUNT(id), date(to_timestamp(added_on/1000000000)) AS added_date").
Where("user_id = ?", userID).
Group("added_date").
Order("added_date DESC").Rows()
if err != nil {
handlers.DoError(w, "Failed to count lessons", err, http.StatusInternalServerError)
return
}
payload := map[string]int{}
for rows.Next() {
var count int
var d time.Time
if err := rows.Scan(&count, &d); err != nil {
handlers.DoError(w, "counting notes", err, http.StatusInternalServerError)
}
payload[d.Format("2006-1-2")] = count
}
handlers.RespondJSON(w, http.StatusOK, payload)
}
func (a *API) getCalendar(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
respondWithCalendar(a.App.DB, w, user.ID)
}
func (a *API) createVerificationToken(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
var account database.Account
err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error
if err != nil {
handlers.DoError(w, "finding account", err, http.StatusInternalServerError)
return
}
if account.EmailVerified {
http.Error(w, "Email already verified", http.StatusGone)
return
}
if account.Email.String == "" {
http.Error(w, "Email not set", http.StatusUnprocessableEntity)
return
}
tok, err := token.Create(a.App.DB, account.UserID, database.TokenTypeEmailVerification)
if err != nil {
handlers.DoError(w, "saving token", err, http.StatusInternalServerError)
return
}
if err := a.App.SendVerificationEmail(account.Email.String, tok.Value); err != nil {
if errors.Cause(err) == mailer.ErrSMTPNotConfigured {
handlers.RespondInvalidSMTPConfig(w)
} else {
handlers.DoError(w, errors.Wrap(err, "sending verification email").Error(), nil, http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusCreated)
}
type verifyEmailPayload struct {
Token string `json:"token"`
}
func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) {
var params verifyEmailPayload
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError)
return
}
var token database.Token
if err := a.App.DB.
Where("value = ? AND type = ?", params.Token, database.TokenTypeEmailVerification).
First(&token).Error; err != nil {
http.Error(w, "invalid token", http.StatusBadRequest)
return
}
if token.UsedAt != nil {
http.Error(w, "invalid token", http.StatusBadRequest)
return
}
// Expire after ttl
if time.Since(token.CreatedAt).Minutes() > 30 {
http.Error(w, "This link has been expired. Please request a new link.", http.StatusGone)
return
}
var account database.Account
if err := a.App.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil {
handlers.DoError(w, "finding account", err, http.StatusInternalServerError)
return
}
if account.EmailVerified {
http.Error(w, "Already verified", http.StatusConflict)
return
}
tx := a.App.DB.Begin()
account.EmailVerified = true
if err := tx.Save(&account).Error; err != nil {
tx.Rollback()
handlers.DoError(w, "updating email_verified", err, http.StatusInternalServerError)
return
}
if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil {
tx.Rollback()
handlers.DoError(w, "updating reset token", err, http.StatusInternalServerError)
return
}
tx.Commit()
var user database.User
if err := a.App.DB.Where("id = ?", token.UserID).First(&user).Error; err != nil {
handlers.DoError(w, "finding user", err, http.StatusInternalServerError)
return
}
s := session.New(user, account)
handlers.RespondJSON(w, http.StatusOK, s)
}
type emailPreferernceParams struct {
InactiveReminder *bool `json:"inactive_reminder"`
ProductUpdate *bool `json:"product_update"`
}
func (p emailPreferernceParams) getInactiveReminder() bool {
if p.InactiveReminder == nil {
return false
}
return *p.InactiveReminder
}
func (p emailPreferernceParams) getProductUpdate() bool {
if p.ProductUpdate == nil {
return false
}
return *p.ProductUpdate
}
func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
var params emailPreferernceParams
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
handlers.DoError(w, "decoding payload", err, http.StatusInternalServerError)
return
}
var pref database.EmailPreference
if err := a.App.DB.Where(database.EmailPreference{UserID: user.ID}).FirstOrCreate(&pref).Error; err != nil {
handlers.DoError(w, "finding pref", err, http.StatusInternalServerError)
return
}
tx := a.App.DB.Begin()
if params.InactiveReminder != nil {
pref.InactiveReminder = params.getInactiveReminder()
}
if params.ProductUpdate != nil {
pref.ProductUpdate = params.getProductUpdate()
}
if err := tx.Save(&pref).Error; err != nil {
tx.Rollback()
handlers.DoError(w, "saving pref", err, http.StatusInternalServerError)
return
}
token, ok := r.Context().Value(helpers.KeyToken).(database.Token)
if ok {
// Mark token as used if the user was authenticated by token
if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil {
tx.Rollback()
handlers.DoError(w, "updating reset token", err, http.StatusInternalServerError)
return
}
}
tx.Commit()
handlers.RespondJSON(w, http.StatusOK, pref)
}
func (a *API) getEmailPreference(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
var pref database.EmailPreference
if err := a.App.DB.Where(database.EmailPreference{UserID: user.ID}).First(&pref).Error; err != nil {
handlers.DoError(w, "finding pref", err, http.StatusInternalServerError)
return
}
presented := presenters.PresentEmailPreference(pref)
handlers.RespondJSON(w, http.StatusOK, presented)
}
type updatePasswordPayload struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
func (a *API) updatePassword(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
var params updatePasswordPayload
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if params.OldPassword == "" || params.NewPassword == "" {
http.Error(w, "invalid params", http.StatusBadRequest)
return
}
var account database.Account
if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
handlers.DoError(w, "getting account", nil, http.StatusInternalServerError)
return
}
password := []byte(params.OldPassword)
if err := bcrypt.CompareHashAndPassword([]byte(account.Password.String), password); err != nil {
log.WithFields(log.Fields{
"user_id": user.ID,
}).Warn("invalid password update attempt")
http.Error(w, "Wrong password", http.StatusUnauthorized)
return
}
if err := validatePassword(params.NewPassword); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
hashedNewPassword, err := bcrypt.GenerateFromPassword([]byte(params.NewPassword), bcrypt.DefaultCost)
if err != nil {
http.Error(w, errors.Wrap(err, "hashing password").Error(), http.StatusInternalServerError)
return
}
if err := a.App.DB.Model(&account).Update("password", string(hashedNewPassword)).Error; err != nil {
http.Error(w, errors.Wrap(err, "updating password").Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// import (
// "encoding/json"
// "net/http"
// "time"
//
// "github.com/dnote/dnote/pkg/server/database"
// "github.com/dnote/dnote/pkg/server/middleware"
// "github.com/dnote/dnote/pkg/server/helpers"
// "github.com/dnote/dnote/pkg/server/log"
// "github.com/dnote/dnote/pkg/server/mailer"
// "github.com/dnote/dnote/pkg/server/presenters"
// "github.com/dnote/dnote/pkg/server/session"
// "github.com/dnote/dnote/pkg/server/token"
// "github.com/jinzhu/gorm"
// "github.com/pkg/errors"
// "golang.org/x/crypto/bcrypt"
// )
//
// type updateProfilePayload struct {
// Email string `json:"email"`
// Password string `json:"password"`
// }
//
// // updateProfile updates user
// func (a *API) updateProfile(w http.ResponseWriter, r *http.Request) {
// user, ok := r.Context().Value(helpers.KeyUser).(database.User)
// if !ok {
// middleware.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
// return
// }
//
// var account database.Account
// if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
// middleware.DoError(w, "getting account", nil, http.StatusInternalServerError)
// return
// }
//
// var params updateProfilePayload
// err := json.NewDecoder(r.Body).Decode(&params)
// if err != nil {
// http.Error(w, errors.Wrap(err, "invalid params").Error(), http.StatusBadRequest)
// return
// }
//
// password := []byte(params.Password)
// if err := bcrypt.CompareHashAndPassword([]byte(account.Password.String), password); err != nil {
// log.WithFields(log.Fields{
// "user_id": user.ID,
// }).Warn("invalid email update attempt")
// http.Error(w, "Wrong password", http.StatusUnauthorized)
// return
// }
//
// // Validate
// if len(params.Email) > 60 {
// http.Error(w, "Email is too long", http.StatusBadRequest)
// return
// }
//
// tx := a.App.DB.Begin()
// if err := tx.Save(&user).Error; err != nil {
// tx.Rollback()
// middleware.DoError(w, "saving user", err, http.StatusInternalServerError)
// return
// }
//
// // check if email was changed
// if params.Email != account.Email.String {
// account.EmailVerified = false
// }
// account.Email.String = params.Email
//
// if err := tx.Save(&account).Error; err != nil {
// tx.Rollback()
// middleware.DoError(w, "saving account", err, http.StatusInternalServerError)
// return
// }
//
// tx.Commit()
//
// a.respondWithSession(a.App.DB, w, user.ID, http.StatusOK)
// }
//
// type updateEmailPayload struct {
// NewEmail string `json:"new_email"`
// NewCipherKeyEnc string `json:"new_cipher_key_enc"`
// OldAuthKey string `json:"old_auth_key"`
// NewAuthKey string `json:"new_auth_key"`
// }
//
// func respondWithCalendar(db *gorm.DB, w http.ResponseWriter, userID int) {
// rows, err := db.Table("notes").Select("COUNT(id), date(to_timestamp(added_on/1000000000)) AS added_date").
// Where("user_id = ?", userID).
// Group("added_date").
// Order("added_date DESC").Rows()
//
// if err != nil {
// middleware.DoError(w, "Failed to count lessons", err, http.StatusInternalServerError)
// return
// }
//
// payload := map[string]int{}
//
// for rows.Next() {
// var count int
// var d time.Time
//
// if err := rows.Scan(&count, &d); err != nil {
// middleware.DoError(w, "counting notes", err, http.StatusInternalServerError)
// }
// payload[d.Format("2006-1-2")] = count
// }
//
// middleware.RespondJSON(w, http.StatusOK, payload)
// }
//
// func (a *API) getCalendar(w http.ResponseWriter, r *http.Request) {
// user, ok := r.Context().Value(helpers.KeyUser).(database.User)
// if !ok {
// middleware.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
// return
// }
//
// respondWithCalendar(a.App.DB, w, user.ID)
// }
//
// func (a *API) createVerificationToken(w http.ResponseWriter, r *http.Request) {
// user, ok := r.Context().Value(helpers.KeyUser).(database.User)
// if !ok {
// middleware.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
// return
// }
//
// var account database.Account
// err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error
// if err != nil {
// middleware.DoError(w, "finding account", err, http.StatusInternalServerError)
// return
// }
//
// if account.EmailVerified {
// http.Error(w, "Email already verified", http.StatusGone)
// return
// }
// if account.Email.String == "" {
// http.Error(w, "Email not set", http.StatusUnprocessableEntity)
// return
// }
//
// tok, err := token.Create(a.App.DB, account.UserID, database.TokenTypeEmailVerification)
// if err != nil {
// middleware.DoError(w, "saving token", err, http.StatusInternalServerError)
// return
// }
//
// if err := a.App.SendVerificationEmail(account.Email.String, tok.Value); err != nil {
// if errors.Cause(err) == mailer.ErrSMTPNotConfigured {
// middleware.RespondInvalidSMTPConfig(w)
// } else {
// middleware.DoError(w, errors.Wrap(err, "sending verification email").Error(), nil, http.StatusInternalServerError)
// }
//
// return
// }
//
// w.WriteHeader(http.StatusCreated)
// }
//
// type verifyEmailPayload struct {
// Token string `json:"token"`
// }
//
// func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) {
// var params verifyEmailPayload
// if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
// middleware.DoError(w, "decoding payload", err, http.StatusInternalServerError)
// return
// }
//
// var token database.Token
// if err := a.App.DB.
// Where("value = ? AND type = ?", params.Token, database.TokenTypeEmailVerification).
// First(&token).Error; err != nil {
// http.Error(w, "invalid token", http.StatusBadRequest)
// return
// }
//
// if token.UsedAt != nil {
// http.Error(w, "invalid token", http.StatusBadRequest)
// return
// }
//
// // Expire after ttl
// if time.Since(token.CreatedAt).Minutes() > 30 {
// http.Error(w, "This link has been expired. Please request a new link.", http.StatusGone)
// return
// }
//
// var account database.Account
// if err := a.App.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil {
// middleware.DoError(w, "finding account", err, http.StatusInternalServerError)
// return
// }
// if account.EmailVerified {
// http.Error(w, "Already verified", http.StatusConflict)
// return
// }
//
// tx := a.App.DB.Begin()
// account.EmailVerified = true
// if err := tx.Save(&account).Error; err != nil {
// tx.Rollback()
// middleware.DoError(w, "updating email_verified", err, http.StatusInternalServerError)
// return
// }
// if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil {
// tx.Rollback()
// middleware.DoError(w, "updating reset token", err, http.StatusInternalServerError)
// return
// }
// tx.Commit()
//
// var user database.User
// if err := a.App.DB.Where("id = ?", token.UserID).First(&user).Error; err != nil {
// middleware.DoError(w, "finding user", err, http.StatusInternalServerError)
// return
// }
//
// s := session.New(user, account)
// middleware.RespondJSON(w, http.StatusOK, s)
// }
//
// type emailPreferernceParams struct {
// InactiveReminder *bool `json:"inactive_reminder"`
// ProductUpdate *bool `json:"product_update"`
// }
//
// func (p emailPreferernceParams) getInactiveReminder() bool {
// if p.InactiveReminder == nil {
// return false
// }
//
// return *p.InactiveReminder
// }
//
// func (p emailPreferernceParams) getProductUpdate() bool {
// if p.ProductUpdate == nil {
// return false
// }
//
// return *p.ProductUpdate
// }
//
// func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) {
// user, ok := r.Context().Value(helpers.KeyUser).(database.User)
// if !ok {
// middleware.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
// return
// }
//
// var params emailPreferernceParams
// if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
// middleware.DoError(w, "decoding payload", err, http.StatusInternalServerError)
// return
// }
//
// var pref database.EmailPreference
// if err := a.App.DB.Where(database.EmailPreference{UserID: user.ID}).FirstOrCreate(&pref).Error; err != nil {
// middleware.DoError(w, "finding pref", err, http.StatusInternalServerError)
// return
// }
//
// tx := a.App.DB.Begin()
//
// if params.InactiveReminder != nil {
// pref.InactiveReminder = params.getInactiveReminder()
// }
// if params.ProductUpdate != nil {
// pref.ProductUpdate = params.getProductUpdate()
// }
//
// if err := tx.Save(&pref).Error; err != nil {
// tx.Rollback()
// middleware.DoError(w, "saving pref", err, http.StatusInternalServerError)
// return
// }
//
// token, ok := r.Context().Value(helpers.KeyToken).(database.Token)
// if ok {
// // Mark token as used if the user was authenticated by token
// if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil {
// tx.Rollback()
// middleware.DoError(w, "updating reset token", err, http.StatusInternalServerError)
// return
// }
// }
//
// tx.Commit()
//
// middleware.RespondJSON(w, http.StatusOK, pref)
// }
//
// func (a *API) getEmailPreference(w http.ResponseWriter, r *http.Request) {
// user, ok := r.Context().Value(helpers.KeyUser).(database.User)
// if !ok {
// middleware.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
// return
// }
//
// var pref database.EmailPreference
// if err := a.App.DB.Where(database.EmailPreference{UserID: user.ID}).First(&pref).Error; err != nil {
// middleware.DoError(w, "finding pref", err, http.StatusInternalServerError)
// return
// }
//
// presented := presenters.PresentEmailPreference(pref)
// middleware.RespondJSON(w, http.StatusOK, presented)
// }
//
// type updatePasswordPayload struct {
// OldPassword string `json:"old_password"`
// NewPassword string `json:"new_password"`
// }
//
// func (a *API) updatePassword(w http.ResponseWriter, r *http.Request) {
// user, ok := r.Context().Value(helpers.KeyUser).(database.User)
// if !ok {
// middleware.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
// return
// }
//
// var params updatePasswordPayload
// if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
// http.Error(w, err.Error(), http.StatusBadRequest)
// return
// }
// if params.OldPassword == "" || params.NewPassword == "" {
// http.Error(w, "invalid params", http.StatusBadRequest)
// return
// }
//
// var account database.Account
// if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
// middleware.DoError(w, "getting account", nil, http.StatusInternalServerError)
// return
// }
//
// password := []byte(params.OldPassword)
// if err := bcrypt.CompareHashAndPassword([]byte(account.Password.String), password); err != nil {
// log.WithFields(log.Fields{
// "user_id": user.ID,
// }).Warn("invalid password update attempt")
// http.Error(w, "Wrong password", http.StatusUnauthorized)
// return
// }
//
// if err := validatePassword(params.NewPassword); err != nil {
// http.Error(w, err.Error(), http.StatusBadRequest)
// return
// }
//
// hashedNewPassword, err := bcrypt.GenerateFromPassword([]byte(params.NewPassword), bcrypt.DefaultCost)
// if err != nil {
// http.Error(w, errors.Wrap(err, "hashing password").Error(), http.StatusInternalServerError)
// return
// }
//
// if err := a.App.DB.Model(&account).Update("password", string(hashedNewPassword)).Error; err != nil {
// http.Error(w, errors.Wrap(err, "updating password").Error(), http.StatusInternalServerError)
// return
// }
//
// w.WriteHeader(http.StatusOK)
// }

File diff suppressed because it is too large Load diff

View file

@ -1,548 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package api
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/presenters"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/pkg/errors"
)
func TestGetBooks(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
anotherUser := testutils.SetupUserData()
b1 := database.Book{
UserID: user.ID,
Label: "js",
USN: 1123,
Deleted: false,
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
b2 := database.Book{
UserID: user.ID,
Label: "css",
USN: 1125,
Deleted: false,
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
b3 := database.Book{
UserID: anotherUser.ID,
Label: "css",
USN: 1128,
Deleted: false,
}
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
b4 := database.Book{
UserID: user.ID,
Label: "",
USN: 1129,
Deleted: true,
}
testutils.MustExec(t, testutils.DB.Save(&b4), "preparing b4")
// Execute
req := testutils.MakeReq(server.URL, "GET", "/v3/books", "")
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload []presenters.Book
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var b1Record, b2Record database.Book
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&b1Record), "finding b1")
testutils.MustExec(t, testutils.DB.Where("id = ?", b2.ID).First(&b2Record), "finding b2")
testutils.MustExec(t, testutils.DB.Where("id = ?", b2.ID).First(&b2Record), "finding b2")
expected := []presenters.Book{
{
UUID: b2Record.UUID,
CreatedAt: b2Record.CreatedAt,
UpdatedAt: b2Record.UpdatedAt,
Label: b2Record.Label,
USN: b2Record.USN,
},
{
UUID: b1Record.UUID,
CreatedAt: b1Record.CreatedAt,
UpdatedAt: b1Record.UpdatedAt,
Label: b1Record.Label,
USN: b1Record.USN,
},
}
assert.DeepEqual(t, payload, expected, "payload mismatch")
}
func TestGetBooksByName(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
anotherUser := testutils.SetupUserData()
req := testutils.MakeReq(server.URL, "GET", "/v3/books?name=js", "")
b1 := database.Book{
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
b2 := database.Book{
UserID: user.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
b3 := database.Book{
UserID: anotherUser.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
// Execute
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var payload []presenters.Book
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatal(errors.Wrap(err, "decoding payload"))
}
var b1Record database.Book
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&b1Record), "finding b1")
expected := []presenters.Book{
{
UUID: b1Record.UUID,
CreatedAt: b1Record.CreatedAt,
UpdatedAt: b1Record.UpdatedAt,
Label: b1Record.Label,
USN: b1Record.USN,
},
}
assert.DeepEqual(t, payload, expected, "payload mismatch")
}
func TestDeleteBook(t *testing.T) {
testCases := []struct {
label string
deleted bool
expectedB2USN int
expectedMaxUSN int
expectedN2USN int
expectedN3USN int
}{
{
label: "n1 content",
deleted: false,
expectedMaxUSN: 61,
expectedB2USN: 61,
expectedN2USN: 59,
expectedN3USN: 60,
},
{
label: "",
deleted: true,
expectedMaxUSN: 59,
expectedB2USN: 59,
expectedN2USN: 5,
expectedN3USN: 6,
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("originally deleted %t", tc.deleted), func(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 58), "preparing user max_usn")
anotherUser := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Model(&anotherUser).Update("max_usn", 109), "preparing another user max_usn")
b1 := database.Book{
UserID: user.ID,
Label: "js",
USN: 1,
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing a book data")
b2 := database.Book{
UserID: user.ID,
Label: tc.label,
USN: 2,
Deleted: tc.deleted,
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing a book data")
b3 := database.Book{
UserID: anotherUser.ID,
Label: "linux",
USN: 3,
}
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing a book data")
var n2Body string
if !tc.deleted {
n2Body = "n2 content"
}
var n3Body string
if !tc.deleted {
n3Body = "n3 content"
}
n1 := database.Note{
UserID: user.ID,
BookUUID: b1.UUID,
Body: "n1 content",
USN: 4,
}
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing a note data")
n2 := database.Note{
UserID: user.ID,
BookUUID: b2.UUID,
Body: n2Body,
USN: 5,
Deleted: tc.deleted,
}
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing a note data")
n3 := database.Note{
UserID: user.ID,
BookUUID: b2.UUID,
Body: n3Body,
USN: 6,
Deleted: tc.deleted,
}
testutils.MustExec(t, testutils.DB.Save(&n3), "preparing a note data")
n4 := database.Note{
UserID: user.ID,
BookUUID: b2.UUID,
Body: "",
USN: 7,
Deleted: true,
}
testutils.MustExec(t, testutils.DB.Save(&n4), "preparing a note data")
n5 := database.Note{
UserID: anotherUser.ID,
BookUUID: b3.UUID,
Body: "n5 content",
USN: 8,
}
testutils.MustExec(t, testutils.DB.Save(&n5), "preparing a note data")
endpoint := fmt.Sprintf("/v3/books/%s", b2.UUID)
req := testutils.MakeReq(server.URL, "DELETE", endpoint, "")
req.Header.Set("Version", "0.1.1")
req.Header.Set("Origin", "chrome-extension://iaolnfnipkoinabdbbakcmkkdignedce")
// Execute
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, "")
var b1Record, b2Record, b3Record database.Book
var n1Record, n2Record, n3Record, n4Record, n5Record database.Note
var userRecord database.User
var bookCount, noteCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&b1Record), "finding b1")
testutils.MustExec(t, testutils.DB.Where("id = ?", b2.ID).First(&b2Record), "finding b2")
testutils.MustExec(t, testutils.DB.Where("id = ?", b3.ID).First(&b3Record), "finding b3")
testutils.MustExec(t, testutils.DB.Where("id = ?", n1.ID).First(&n1Record), "finding n1")
testutils.MustExec(t, testutils.DB.Where("id = ?", n2.ID).First(&n2Record), "finding n2")
testutils.MustExec(t, testutils.DB.Where("id = ?", n3.ID).First(&n3Record), "finding n3")
testutils.MustExec(t, testutils.DB.Where("id = ?", n4.ID).First(&n4Record), "finding n4")
testutils.MustExec(t, testutils.DB.Where("id = ?", n5.ID).First(&n5Record), "finding n5")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equal(t, bookCount, 3, "book count mismatch")
assert.Equal(t, noteCount, 5, "note count mismatch")
assert.Equal(t, userRecord.MaxUSN, tc.expectedMaxUSN, "user max_usn mismatch")
assert.Equal(t, b1Record.Deleted, false, "b1 deleted mismatch")
assert.Equal(t, b1Record.Label, b1.Label, "b1 content mismatch")
assert.Equal(t, b1Record.USN, b1.USN, "b1 usn mismatch")
assert.Equal(t, b2Record.Deleted, true, "b2 deleted mismatch")
assert.Equal(t, b2Record.Label, "", "b2 content mismatch")
assert.Equal(t, b2Record.USN, tc.expectedB2USN, "b2 usn mismatch")
assert.Equal(t, b3Record.Deleted, false, "b3 deleted mismatch")
assert.Equal(t, b3Record.Label, b3.Label, "b3 content mismatch")
assert.Equal(t, b3Record.USN, b3.USN, "b3 usn mismatch")
assert.Equal(t, n1Record.USN, n1.USN, "n1 usn mismatch")
assert.Equal(t, n1Record.Deleted, false, "n1 deleted mismatch")
assert.Equal(t, n1Record.Body, n1.Body, "n1 content mismatch")
assert.Equal(t, n2Record.USN, tc.expectedN2USN, "n2 usn mismatch")
assert.Equal(t, n2Record.Deleted, true, "n2 deleted mismatch")
assert.Equal(t, n2Record.Body, "", "n2 content mismatch")
assert.Equal(t, n3Record.USN, tc.expectedN3USN, "n3 usn mismatch")
assert.Equal(t, n3Record.Deleted, true, "n3 deleted mismatch")
assert.Equal(t, n3Record.Body, "", "n3 content mismatch")
// if already deleted, usn should remain the same and hence should not contribute to bumping the max_usn
assert.Equal(t, n4Record.USN, n4.USN, "n4 usn mismatch")
assert.Equal(t, n4Record.Deleted, true, "n4 deleted mismatch")
assert.Equal(t, n4Record.Body, "", "n4 content mismatch")
assert.Equal(t, n5Record.USN, n5.USN, "n5 usn mismatch")
assert.Equal(t, n5Record.Deleted, false, "n5 deleted mismatch")
assert.Equal(t, n5Record.Body, n5.Body, "n5 content mismatch")
})
}
}
func TestCreateBook(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
req := testutils.MakeReq(server.URL, "POST", "/v3/books", `{"name": "js"}`)
req.Header.Set("Version", "0.1.1")
req.Header.Set("Origin", "chrome-extension://iaolnfnipkoinabdbbakcmkkdignedce")
// Execute
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
var bookRecord database.Book
var userRecord database.User
var bookCount, noteCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
maxUSN := 102
assert.Equalf(t, bookCount, 1, "book count mismatch")
assert.Equalf(t, noteCount, 0, "note count mismatch")
assert.NotEqual(t, bookRecord.UUID, "", "book uuid should have been generated")
assert.Equal(t, bookRecord.Label, "js", "book name mismatch")
assert.Equal(t, bookRecord.UserID, user.ID, "book user_id mismatch")
assert.Equal(t, bookRecord.USN, maxUSN, "book user_id mismatch")
assert.Equal(t, userRecord.MaxUSN, maxUSN, "user max_usn mismatch")
var got CreateBookResp
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatal(errors.Wrap(err, "decoding got"))
}
expected := CreateBookResp{
Book: presenters.Book{
UUID: bookRecord.UUID,
USN: bookRecord.USN,
CreatedAt: bookRecord.CreatedAt,
UpdatedAt: bookRecord.UpdatedAt,
Label: "js",
},
}
assert.DeepEqual(t, got, expected, "payload mismatch")
}
func TestCreateBookDuplicate(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
b1 := database.Book{
UserID: user.ID,
Label: "js",
USN: 58,
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing book data")
// Execute
req := testutils.MakeReq(server.URL, "POST", "/v3/books", `{"name": "js"}`)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusConflict, "")
var bookRecord database.Book
var bookCount, noteCount int
var userRecord database.User
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equalf(t, bookCount, 1, "book count mismatch")
assert.Equalf(t, noteCount, 0, "note count mismatch")
assert.Equal(t, bookRecord.Label, "js", "book name mismatch")
assert.Equal(t, bookRecord.UserID, user.ID, "book user_id mismatch")
assert.Equal(t, bookRecord.USN, b1.USN, "book usn mismatch")
assert.Equal(t, userRecord.MaxUSN, 101, "user max_usn mismatch")
}
func TestUpdateBook(t *testing.T) {
updatedLabel := "updated-label"
b1UUID := "ead8790f-aff9-4bdf-8eec-f734ccd29202"
b2UUID := "0ecaac96-8d72-4e04-8925-5a21b79a16da"
testCases := []struct {
payload string
bookUUID string
bookDeleted bool
bookLabel string
expectedBookLabel string
}{
{
payload: fmt.Sprintf(`{
"name": "%s"
}`, updatedLabel),
bookUUID: b1UUID,
bookDeleted: false,
bookLabel: "original-label",
expectedBookLabel: updatedLabel,
},
// if a deleted book is updated, it should be un-deleted
{
payload: fmt.Sprintf(`{
"name": "%s"
}`, updatedLabel),
bookUUID: b1UUID,
bookDeleted: true,
bookLabel: "",
expectedBookLabel: updatedLabel,
},
}
for idx, tc := range testCases {
func() {
defer testutils.ClearData(testutils.DB)
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
user := testutils.SetupUserData()
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
b1 := database.Book{
UUID: tc.bookUUID,
UserID: user.ID,
Label: tc.bookLabel,
Deleted: tc.bookDeleted,
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
b2 := database.Book{
UUID: b2UUID,
UserID: user.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
// Executdb,e
endpoint := fmt.Sprintf("/v3/books/%s", tc.bookUUID)
req := testutils.MakeReq(server.URL, "PATCH", endpoint, tc.payload)
res := testutils.HTTPAuthDo(t, req, user)
// Test
assert.StatusCodeEquals(t, res, http.StatusOK, fmt.Sprintf("status code mismatch for test case %d", idx))
var bookRecord database.Book
var userRecord database.User
var noteCount, bookCount int
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(&noteCount), "counting notes")
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&bookRecord), "finding book")
testutils.MustExec(t, testutils.DB.Where("id = ?", user.ID).First(&userRecord), "finding user record")
assert.Equalf(t, bookCount, 2, "book count mismatch")
assert.Equalf(t, noteCount, 0, "note count mismatch")
assert.Equalf(t, bookRecord.UUID, tc.bookUUID, "book uuid mismatch")
assert.Equalf(t, bookRecord.Label, tc.expectedBookLabel, "book label mismatch")
assert.Equalf(t, bookRecord.USN, 102, "book usn mismatch")
assert.Equalf(t, bookRecord.Deleted, false, "book Deleted mismatch")
assert.Equal(t, userRecord.MaxUSN, 102, fmt.Sprintf("user max_usn mismatch for test case %d", idx))
}()
}
}

View file

@ -1,304 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package api
import (
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"time"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/handlers"
"github.com/dnote/dnote/pkg/server/helpers"
"github.com/dnote/dnote/pkg/server/log"
"github.com/pkg/errors"
)
// fullSyncBefore is the system-wide timestamp that represents the point in time
// before which clients must perform a full-sync rather than incremental sync.
const fullSyncBefore = 0
// SyncFragment contains a piece of information about the server's state.
// It is used to transfer the server's state to the client gradually without having to
// transfer the whole state at once.
type SyncFragment struct {
FragMaxUSN int `json:"frag_max_usn"`
UserMaxUSN int `json:"user_max_usn"`
CurrentTime int64 `json:"current_time"`
Notes []SyncFragNote `json:"notes"`
Books []SyncFragBook `json:"books"`
ExpungedNotes []string `json:"expunged_notes"`
ExpungedBooks []string `json:"expunged_books"`
}
// SyncFragNote represents a note in a sync fragment and contains only the necessary information
// for the client to sync the note locally
type SyncFragNote struct {
UUID string `json:"uuid"`
BookUUID string `json:"book_uuid"`
USN int `json:"usn"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AddedOn int64 `json:"added_on"`
EditedOn int64 `json:"edited_on"`
Body string `json:"content"`
Public bool `json:"public"`
Deleted bool `json:"deleted"`
}
// NewFragNote presents the given note as a SyncFragNote
func NewFragNote(note database.Note) SyncFragNote {
return SyncFragNote{
UUID: note.UUID,
USN: note.USN,
CreatedAt: note.CreatedAt,
UpdatedAt: note.UpdatedAt,
AddedOn: note.AddedOn,
EditedOn: note.EditedOn,
Body: note.Body,
Public: note.Public,
Deleted: note.Deleted,
BookUUID: note.BookUUID,
}
}
// SyncFragBook represents a book in a sync fragment and contains only the necessary information
// for the client to sync the note locally
type SyncFragBook struct {
UUID string `json:"uuid"`
USN int `json:"usn"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AddedOn int64 `json:"added_on"`
Label string `json:"label"`
Deleted bool `json:"deleted"`
}
// NewFragBook presents the given book as a SyncFragBook
func NewFragBook(book database.Book) SyncFragBook {
return SyncFragBook{
UUID: book.UUID,
USN: book.USN,
CreatedAt: book.CreatedAt,
UpdatedAt: book.UpdatedAt,
AddedOn: book.AddedOn,
Label: book.Label,
Deleted: book.Deleted,
}
}
type usnItem struct {
usn int
val interface{}
}
type queryParamError struct {
key string
value string
message string
}
func (e *queryParamError) Error() string {
return fmt.Sprintf("invalid query param %s=%s. %s", e.key, e.value, e.message)
}
func (a *API) newFragment(userID, userMaxUSN, afterUSN, limit int) (SyncFragment, error) {
var notes []database.Note
if err := a.App.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(&notes).Error; err != nil {
return SyncFragment{}, nil
}
var books []database.Book
if err := a.App.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(&books).Error; err != nil {
return SyncFragment{}, nil
}
var items []usnItem
for _, note := range notes {
i := usnItem{
usn: note.USN,
val: note,
}
items = append(items, i)
}
for _, book := range books {
i := usnItem{
usn: book.USN,
val: book,
}
items = append(items, i)
}
// order by usn in ascending order
sort.Slice(items, func(i, j int) bool {
return items[i].usn < items[j].usn
})
fragNotes := []SyncFragNote{}
fragBooks := []SyncFragBook{}
fragExpungedNotes := []string{}
fragExpungedBooks := []string{}
fragMaxUSN := 0
for i := 0; i < limit; i++ {
if i > len(items)-1 {
break
}
item := items[i]
fragMaxUSN = item.usn
switch v := item.val.(type) {
case database.Note:
note := item.val.(database.Note)
if note.Deleted {
fragExpungedNotes = append(fragExpungedNotes, note.UUID)
} else {
fragNotes = append(fragNotes, NewFragNote(note))
}
case database.Book:
book := item.val.(database.Book)
if book.Deleted {
fragExpungedBooks = append(fragExpungedBooks, book.UUID)
} else {
fragBooks = append(fragBooks, NewFragBook(book))
}
default:
return SyncFragment{}, errors.Errorf("unknown internal item type %s", v)
}
}
ret := SyncFragment{
FragMaxUSN: fragMaxUSN,
UserMaxUSN: userMaxUSN,
CurrentTime: a.App.Clock.Now().Unix(),
Notes: fragNotes,
Books: fragBooks,
ExpungedNotes: fragExpungedNotes,
ExpungedBooks: fragExpungedBooks,
}
return ret, nil
}
func parseGetSyncFragmentQuery(q url.Values) (afterUSN, limit int, err error) {
afterUSNStr := q.Get("after_usn")
limitStr := q.Get("limit")
if len(afterUSNStr) > 0 {
afterUSN, err = strconv.Atoi(afterUSNStr)
if err != nil {
err = errors.Wrap(err, "invalid after_usn")
return
}
} else {
afterUSN = 0
}
if len(limitStr) > 0 {
l, e := strconv.Atoi(limitStr)
if e != nil {
err = errors.Wrap(e, "invalid limit")
return
}
if l > 100 {
err = &queryParamError{
key: "limit",
value: limitStr,
message: "maximum value is 100",
}
return
}
limit = l
} else {
limit = 100
}
return
}
// GetSyncFragmentResp represents a response from GetSyncFragment handler
type GetSyncFragmentResp struct {
Fragment SyncFragment `json:"fragment"`
}
// GetSyncFragment responds with a sync fragment
func (a *API) GetSyncFragment(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
afterUSN, limit, err := parseGetSyncFragmentQuery(r.URL.Query())
if err != nil {
handlers.DoError(w, "parsing query params", err, http.StatusInternalServerError)
return
}
fragment, err := a.newFragment(user.ID, user.MaxUSN, afterUSN, limit)
if err != nil {
handlers.DoError(w, "getting fragment", err, http.StatusInternalServerError)
return
}
response := GetSyncFragmentResp{
Fragment: fragment,
}
handlers.RespondJSON(w, http.StatusOK, response)
}
// GetSyncStateResp represents a response from GetSyncFragment handler
type GetSyncStateResp struct {
FullSyncBefore int `json:"full_sync_before"`
MaxUSN int `json:"max_usn"`
CurrentTime int64 `json:"current_time"`
}
// GetSyncState responds with a sync fragment
func (a *API) GetSyncState(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
response := GetSyncStateResp{
FullSyncBefore: fullSyncBefore,
MaxUSN: user.MaxUSN,
// TODO: exposing server time means we probably shouldn't seed random generator with time?
CurrentTime: a.App.Clock.Now().Unix(),
}
log.WithFields(log.Fields{
"user_id": user.ID,
"resp": response,
}).Info("getting sync state")
handlers.RespondJSON(w, http.StatusOK, response)
}

View file

@ -1,93 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package api
import (
"fmt"
"net/url"
"reflect"
"testing"
"github.com/dnote/dnote/pkg/assert"
"github.com/pkg/errors"
)
func TestParseGetSyncFragmentQuery(t *testing.T) {
testCases := []struct {
input string
afterUSN int
limit int
err error
}{
{
input: `after_usn=50&limit=50`,
afterUSN: 50,
limit: 50,
err: nil,
},
{
input: `limit=50`,
afterUSN: 0,
limit: 50,
err: nil,
},
{
input: `after_usn=50`,
afterUSN: 50,
limit: 100,
err: nil,
},
{
input: `after_usn=50&limit=100`,
afterUSN: 50,
limit: 100,
err: nil,
},
{
input: "",
afterUSN: 0,
limit: 100,
err: nil,
},
{
input: "limit=101",
afterUSN: 0,
limit: 0,
err: &queryParamError{
key: "limit",
value: "101",
message: "maximum value is 100",
},
},
}
for idx, tc := range testCases {
q, err := url.ParseQuery(tc.input)
if err != nil {
t.Fatal(errors.Wrap(err, "parsing test input"))
}
afterUSN, limit, err := parseGetSyncFragmentQuery(q)
ok := reflect.DeepEqual(err, tc.err)
assert.Equal(t, ok, true, fmt.Sprintf("err mismatch for test case %d. Expected: %+v. Got: %+v", idx, tc.err, err))
assert.Equal(t, afterUSN, tc.afterUSN, fmt.Sprintf("afterUSN mismatch for test case %d", idx))
assert.Equal(t, limit, tc.limit, fmt.Sprintf("limit mismatch for test case %d", idx))
}
}