diff --git a/go.mod b/go.mod index d2758dd8..047b8e5d 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 + github.com/rs/cors v1.11.1 github.com/testcontainers/testcontainers-go v0.38.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.38.0 golang.org/x/crypto v0.40.0 @@ -65,7 +66,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/rs/cors v1.11.1 // indirect github.com/shirou/gopsutil/v4 v4.25.5 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/testify v1.10.0 // indirect diff --git a/handler/categories.go b/handler/categories.go index 38212c6d..1ef2c451 100644 --- a/handler/categories.go +++ b/handler/categories.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "github.com/oullin/database" + "github.com/oullin/database/repository" "github.com/oullin/database/repository/pagination" "github.com/oullin/handler/paginate" "github.com/oullin/handler/payload" @@ -11,16 +12,14 @@ import ( baseHttp "net/http" ) -type categoriesRepo interface { - GetAll(pagination.Paginate) (*pagination.Pagination[database.Category], error) -} - type CategoriesHandler struct { - Categories categoriesRepo + Categories *repository.Categories } -func MakeCategoriesHandler(categories categoriesRepo) CategoriesHandler { - return CategoriesHandler{Categories: categories} +func MakeCategoriesHandler(categories *repository.Categories) CategoriesHandler { + return CategoriesHandler{ + Categories: categories, + } } func (h *CategoriesHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { diff --git a/handler/categories_posts_test.go b/handler/categories_posts_test.go deleted file mode 100644 index b4fa20a7..00000000 --- a/handler/categories_posts_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package handler - -import ( - "bytes" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "testing" - - "github.com/oullin/database" - "github.com/oullin/database/repository/pagination" - "github.com/oullin/database/repository/queries" - "github.com/oullin/handler/payload" -) - -// stubCategories simulates repository.Categories -type stubCategories struct { - result *pagination.Pagination[database.Category] - err error -} - -func (s stubCategories) GetAll(p pagination.Paginate) (*pagination.Pagination[database.Category], error) { - return s.result, s.err -} - -// stubPosts simulates repository.Posts -type stubPosts struct { - list *pagination.Pagination[database.Post] - err error - item *database.Post -} - -func (s stubPosts) GetAll(filters queries.PostFilters, p pagination.Paginate) (*pagination.Pagination[database.Post], error) { - return s.list, s.err -} - -func (s stubPosts) FindBy(slug string) *database.Post { - return s.item -} - -func TestCategoriesHandlerIndex(t *testing.T) { - pag := pagination.Paginate{Page: 1, Limit: 5} - pag.SetNumItems(1) - cats := []database.Category{{UUID: "1", Name: "Cat", Slug: "cat", Description: "desc"}} - repo := stubCategories{result: pagination.MakePagination(cats, pag)} - h := MakeCategoriesHandler(&repo) - - req := httptest.NewRequest("GET", "/categories", nil) - rec := httptest.NewRecorder() - if err := h.Index(rec, req); err != nil { - t.Fatalf("err: %v", err) - } - if rec.Code != http.StatusOK { - t.Fatalf("status %d", rec.Code) - } - - var resp struct { - Data []struct{ Slug string } `json:"data"` - } - if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { - t.Fatalf("decode: %v", err) - } - if len(resp.Data) != 1 || resp.Data[0].Slug != "cat" { - t.Fatalf("unexpected resp %#v", resp) - } - - repo.err = errors.New("fail") - rec2 := httptest.NewRecorder() - if h.Index(rec2, req) == nil { - t.Fatalf("expected error") - } -} - -func TestPostsHandlerIndex(t *testing.T) { - post := database.Post{UUID: "p1", Slug: "slug", Title: "title"} - pag := pagination.Paginate{Page: 1, Limit: 10} - pag.SetNumItems(1) - repo := stubPosts{list: pagination.MakePagination([]database.Post{post}, pag)} - h := MakePostsHandler(&repo) - - body, _ := json.Marshal(payload.IndexRequestBody{Title: "title"}) - req := httptest.NewRequest("POST", "/posts", bytes.NewReader(body)) - rec := httptest.NewRecorder() - if err := h.Index(rec, req); err != nil { - t.Fatalf("err: %v", err) - } - if rec.Code != http.StatusOK { - t.Fatalf("status %d", rec.Code) - } - - repo.err = errors.New("fail") - rec2 := httptest.NewRecorder() - if h.Index(rec2, req) == nil { - t.Fatalf("expected error") - } - - badReq := httptest.NewRequest("POST", "/posts", bytes.NewReader([]byte("{"))) - rec3 := httptest.NewRecorder() - if h.Index(rec3, badReq) == nil { - t.Fatalf("expected parse error") - } -} - -func TestPostsHandlerShow(t *testing.T) { - post := database.Post{UUID: "p1", Slug: "slug", Title: "title"} - repo := stubPosts{item: &post} - h := MakePostsHandler(&repo) - - req := httptest.NewRequest("GET", "/posts/slug", nil) - req.SetPathValue("slug", "slug") - rec := httptest.NewRecorder() - if err := h.Show(rec, req); err != nil { - t.Fatalf("err: %v", err) - } - if rec.Code != http.StatusOK { - t.Fatalf("status %d", rec.Code) - } - - req2 := httptest.NewRequest("GET", "/posts/", nil) - rec2 := httptest.NewRecorder() - if h.Show(rec2, req2) == nil { - t.Fatalf("expected bad request") - } - - repo.item = nil - req3 := httptest.NewRequest("GET", "/posts/slug", nil) - req3.SetPathValue("slug", "slug") - rec3 := httptest.NewRecorder() - if h.Show(rec3, req3) == nil { - t.Fatalf("expected not found") - } -} diff --git a/handler/categories_test.go b/handler/categories_test.go new file mode 100644 index 00000000..210f033d --- /dev/null +++ b/handler/categories_test.go @@ -0,0 +1,53 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + "github.com/oullin/database" + "github.com/oullin/database/repository" + "github.com/oullin/database/repository/pagination" + "github.com/oullin/handler/payload" + handlertests "github.com/oullin/handler/tests" +) + +func TestCategoriesHandlerIndex_Success(t *testing.T) { + conn, author := handlertests.MakeTestDB(t) + published := time.Now() + post := database.Post{UUID: uuid.NewString(), AuthorID: author.ID, Slug: "hello", Title: "Hello", Excerpt: "Ex", Content: "Body", PublishedAt: &published} + if err := conn.Sql().Create(&post).Error; err != nil { + t.Fatalf("create post: %v", err) + } + cat := database.Category{UUID: uuid.NewString(), Name: "Cat", Slug: "cat", Description: "desc"} + if err := conn.Sql().Create(&cat).Error; err != nil { + t.Fatalf("create category: %v", err) + } + link := database.PostCategory{PostID: post.ID, CategoryID: cat.ID} + if err := conn.Sql().Create(&link).Error; err != nil { + t.Fatalf("create link: %v", err) + } + + h := MakeCategoriesHandler(&repository.Categories{DB: conn}) + + req := httptest.NewRequest("GET", "/categories", nil) + rec := httptest.NewRecorder() + + if err := h.Index(rec, req); err != nil { + t.Fatalf("index err: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + + var resp pagination.Pagination[payload.CategoryResponse] + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Data) != 1 || resp.Data[0].Slug != "cat" { + t.Fatalf("unexpected data: %+v", resp.Data) + } +} diff --git a/handler/education_test.go b/handler/education_test.go new file mode 100644 index 00000000..fa5e9f18 --- /dev/null +++ b/handler/education_test.go @@ -0,0 +1,12 @@ +package handler + +import "testing" + +func TestEducationHandler(t *testing.T) { + runFileHandlerTest(t, fileHandlerTestCase{ + make: func(f string) fileHandler { return MakeEducationHandler(f) }, + endpoint: "/education", + data: []map[string]string{{"uuid": "1"}}, + assert: assertArrayUUID1, + }) +} diff --git a/handler/experience_test.go b/handler/experience_test.go new file mode 100644 index 00000000..1effb6e7 --- /dev/null +++ b/handler/experience_test.go @@ -0,0 +1,12 @@ +package handler + +import "testing" + +func TestExperienceHandler(t *testing.T) { + runFileHandlerTest(t, fileHandlerTestCase{ + make: func(f string) fileHandler { return MakeExperienceHandler(f) }, + endpoint: "/experience", + data: []map[string]string{{"uuid": "1"}}, + assert: assertArrayUUID1, + }) +} diff --git a/handler/file_handler_test.go b/handler/file_handler_test.go new file mode 100644 index 00000000..57ed7002 --- /dev/null +++ b/handler/file_handler_test.go @@ -0,0 +1,87 @@ +package handler + +import ( + "encoding/json" + baseHttp "net/http" + "net/http/httptest" + "os" + "testing" + + handlertests "github.com/oullin/handler/tests" + pkgHttp "github.com/oullin/pkg/http" +) + +type fileHandler interface { + Handle(baseHttp.ResponseWriter, *baseHttp.Request) *pkgHttp.ApiError +} + +type fileHandlerTestCase struct { + make func(string) fileHandler + endpoint string + data interface{} + assert func(*testing.T, any) +} + +func runFileHandlerTest(t *testing.T, tc fileHandlerTestCase) { + file := handlertests.WriteJSON(t, handlertests.TestEnvelope{Version: "v1", Data: tc.data}) + defer os.Remove(file) + + h := tc.make(file) + req := httptest.NewRequest("GET", tc.endpoint, nil) + rec := httptest.NewRecorder() + if err := h.Handle(rec, req); err != nil { + t.Fatalf("err: %v", err) + } + if rec.Code != baseHttp.StatusOK { + t.Fatalf("status %d", rec.Code) + } + + var resp handlertests.TestEnvelope + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Version != "v1" { + t.Fatalf("version %s", resp.Version) + } + tc.assert(t, resp.Data) + + req2 := httptest.NewRequest("GET", tc.endpoint, nil) + req2.Header.Set("If-None-Match", "\"v1\"") + rec2 := httptest.NewRecorder() + if err := h.Handle(rec2, req2); err != nil { + t.Fatalf("err: %v", err) + } + if rec2.Code != baseHttp.StatusNotModified { + t.Fatalf("status %d", rec2.Code) + } + + badF, _ := os.CreateTemp("", "bad.json") + badF.WriteString("{") + badF.Close() + defer os.Remove(badF.Name()) + + bad := tc.make(badF.Name()) + rec3 := httptest.NewRecorder() + req3 := httptest.NewRequest("GET", tc.endpoint, nil) + if bad.Handle(rec3, req3) == nil { + t.Fatalf("expected error") + } +} + +func assertArrayUUID1(t *testing.T, data any) { + arr, ok := data.([]interface{}) + if !ok || len(arr) != 1 { + t.Fatalf("unexpected data: %+v", data) + } + m, ok := arr[0].(map[string]interface{}) + if !ok || m["uuid"] != "1" { + t.Fatalf("unexpected payload: %+v", data) + } +} + +func assertNicknameNick(t *testing.T, data any) { + obj, ok := data.(map[string]interface{}) + if !ok || obj["nickname"] != "nick" { + t.Fatalf("unexpected payload: %+v", data) + } +} diff --git a/handler/file_handlers_more_test.go b/handler/file_handlers_more_test.go deleted file mode 100644 index a2677560..00000000 --- a/handler/file_handlers_more_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "testing" - - pkghttp "github.com/oullin/pkg/http" -) - -type testData struct { - Version string `json:"version"` - Data any `json:"data"` -} - -func writeJSON(t *testing.T, v any) string { - f, err := os.CreateTemp("", "data.json") - if err != nil { - t.Fatalf("tmp: %v", err) - } - enc := json.NewEncoder(f) - if err := enc.Encode(v); err != nil { - t.Fatalf("encode: %v", err) - } - f.Close() - return f.Name() -} - -func runFileHandlerTest(t *testing.T, makeFn func(string) interface { - Handle(http.ResponseWriter, *http.Request) *pkghttp.ApiError -}, path string) { - file := writeJSON(t, testData{Version: "v1", Data: []map[string]string{{"id": "1"}}}) - defer os.Remove(file) - h := makeFn(file) - - req := httptest.NewRequest("GET", path, nil) - rec := httptest.NewRecorder() - if err := h.Handle(rec, req); err != nil { - t.Fatalf("err: %v", err) - } - if rec.Code != http.StatusOK { - t.Fatalf("status %d", rec.Code) - } - - req2 := httptest.NewRequest("GET", path, nil) - req2.Header.Set("If-None-Match", "\"v1\"") - rec2 := httptest.NewRecorder() - if err := h.Handle(rec2, req2); err != nil { - t.Fatalf("err: %v", err) - } - if rec2.Code != http.StatusNotModified { - t.Fatalf("status %d", rec2.Code) - } - - badF, _ := os.CreateTemp("", "bad.json") - badF.WriteString("{") - badF.Close() - defer os.Remove(badF.Name()) - bad := makeFn(badF.Name()) - rec3 := httptest.NewRecorder() - req3 := httptest.NewRequest("GET", path, nil) - if bad.Handle(rec3, req3) == nil { - t.Fatalf("expected error") - } -} - -func TestAdditionalFileHandlers(t *testing.T) { - runFileHandlerTest(t, func(p string) interface { - Handle(http.ResponseWriter, *http.Request) *pkghttp.ApiError - } { - h := MakeEducationHandler(p) - return h - }, "/education") - - runFileHandlerTest(t, func(p string) interface { - Handle(http.ResponseWriter, *http.Request) *pkghttp.ApiError - } { - h := MakeExperienceHandler(p) - return h - }, "/experience") - - runFileHandlerTest(t, func(p string) interface { - Handle(http.ResponseWriter, *http.Request) *pkghttp.ApiError - } { - h := MakeProjectsHandler(p) - return h - }, "/projects") - - runFileHandlerTest(t, func(p string) interface { - Handle(http.ResponseWriter, *http.Request) *pkghttp.ApiError - } { - h := MakeRecommendationsHandler(p) - return h - }, "/recommendations") - - runFileHandlerTest(t, func(p string) interface { - Handle(http.ResponseWriter, *http.Request) *pkghttp.ApiError - } { - h := MakeTalksHandler(p) - return h - }, "/talks") -} diff --git a/handler/file_handlers_test.go b/handler/file_handlers_test.go deleted file mode 100644 index bbcd6414..00000000 --- a/handler/file_handlers_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "testing" -) - -type profileData struct { - Version string `json:"version"` - Data any `json:"data"` -} - -func writeTempJSON(t *testing.T, v any) string { - f, err := os.CreateTemp("", "data.json") - if err != nil { - t.Fatalf("tmp: %v", err) - } - enc := json.NewEncoder(f) - if err := enc.Encode(v); err != nil { - t.Fatalf("encode: %v", err) - } - f.Close() - return f.Name() -} - -func TestProfileHandlerHandle(t *testing.T) { - file := writeTempJSON(t, profileData{Version: "v1", Data: map[string]string{"nickname": "nick"}}) - defer os.Remove(file) - h := MakeProfileHandler(file) - - // ok response - req := httptest.NewRequest("GET", "/profile", nil) - rec := httptest.NewRecorder() - if err := h.Handle(rec, req); err != nil { - t.Fatalf("err: %v", err) - } - if rec.Code != http.StatusOK { - t.Fatalf("status %d", rec.Code) - } - - // cached request - req2 := httptest.NewRequest("GET", "/profile", nil) - req2.Header.Set("If-None-Match", "\"v1\"") - rec2 := httptest.NewRecorder() - if err := h.Handle(rec2, req2); err != nil { - t.Fatalf("err: %v", err) - } - if rec2.Code != http.StatusNotModified { - t.Fatalf("status %d", rec2.Code) - } - - // error on parse - badF, _ := os.CreateTemp("", "bad.json") - badF.WriteString("{invalid") - badF.Close() - badFile := badF.Name() - defer os.Remove(badFile) - bad := MakeProfileHandler(badFile) - req3 := httptest.NewRequest("GET", "/profile", nil) - rec3 := httptest.NewRecorder() - if bad.Handle(rec3, req3) == nil { - t.Fatalf("expected error") - } -} - -func TestSocialHandlerHandle(t *testing.T) { - file := writeTempJSON(t, profileData{Version: "v1", Data: []map[string]string{{"uuid": "1"}}}) - defer os.Remove(file) - h := MakeSocialHandler(file) - - req := httptest.NewRequest("GET", "/social", nil) - rec := httptest.NewRecorder() - if err := h.Handle(rec, req); err != nil { - t.Fatalf("err: %v", err) - } - if rec.Code != http.StatusOK { - t.Fatalf("status %d", rec.Code) - } -} diff --git a/handler/payload/categories_test.go b/handler/payload/categories_test.go index 8883969d..41f93858 100644 --- a/handler/payload/categories_test.go +++ b/handler/payload/categories_test.go @@ -13,11 +13,3 @@ func TestGetCategoriesResponse(t *testing.T) { t.Fatalf("unexpected %#v", r) } } - -func TestGetTagsResponse(t *testing.T) { - tags := []database.Tag{{UUID: "1", Name: "n", Slug: "s", Description: "d"}} - r := GetTagsResponse(tags) - if len(r) != 1 || r[0].Slug != "s" { - t.Fatalf("unexpected %#v", r) - } -} diff --git a/handler/payload/education_test.go b/handler/payload/education_test.go new file mode 100644 index 00000000..7fe134e7 --- /dev/null +++ b/handler/payload/education_test.go @@ -0,0 +1,17 @@ +package payload + +import ( + "encoding/json" + "testing" +) + +func TestEducationResponseJSON(t *testing.T) { + body := []byte(`{"version":"v1","data":[{"uuid":"u","icon":"i","school":"s","degree":"d","field":"f","description":"desc","graduated_at":"g","issuing_country":"c"}]}`) + var res EducationResponse + if err := json.Unmarshal(body, &res); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if res.Version != "v1" || len(res.Data) != 1 || res.Data[0].UUID != "u" { + t.Fatalf("unexpected response: %+v", res) + } +} diff --git a/handler/payload/experience_test.go b/handler/payload/experience_test.go new file mode 100644 index 00000000..4e1471e4 --- /dev/null +++ b/handler/payload/experience_test.go @@ -0,0 +1,17 @@ +package payload + +import ( + "encoding/json" + "testing" +) + +func TestExperienceResponseJSON(t *testing.T) { + body := []byte(`{"version":"v1","data":[{"uuid":"u","company":"c","employment_type":"e","location_type":"l","position":"p","start_date":"sd","end_date":"ed","summary":"s","country":"co","city":"ci","skills":"sk"}]}`) + var res ExperienceResponse + if err := json.Unmarshal(body, &res); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if res.Version != "v1" || len(res.Data) != 1 || res.Data[0].UUID != "u" { + t.Fatalf("unexpected response: %+v", res) + } +} diff --git a/handler/payload/posts_filters_test.go b/handler/payload/posts_filters_test.go new file mode 100644 index 00000000..e24aff91 --- /dev/null +++ b/handler/payload/posts_filters_test.go @@ -0,0 +1,11 @@ +package payload + +import "testing" + +func TestGetPostsFiltersFrom(t *testing.T) { + req := IndexRequestBody{Title: "t", Author: "a", Category: "c", Tag: "g", Text: "x"} + f := GetPostsFiltersFrom(req) + if f.Title != "t" || f.Author != "a" || f.Category != "c" || f.Tag != "g" || f.Text != "x" { + t.Fatalf("unexpected filters: %+v", f) + } +} diff --git a/handler/payload/posts_test.go b/handler/payload/posts_response_test.go similarity index 61% rename from handler/payload/posts_test.go rename to handler/payload/posts_response_test.go index d3b08111..addc9b70 100644 --- a/handler/payload/posts_test.go +++ b/handler/payload/posts_response_test.go @@ -1,29 +1,12 @@ package payload import ( - "net/http/httptest" "testing" "time" "github.com/oullin/database" ) -func TestGetPostsFiltersFrom(t *testing.T) { - req := IndexRequestBody{Title: "t", Author: "a", Category: "c", Tag: "g", Text: "x"} - f := GetPostsFiltersFrom(req) - if f.Title != "t" || f.Author != "a" || f.Category != "c" || f.Tag != "g" || f.Text != "x" { - t.Fatalf("unexpected filters: %+v", f) - } -} - -func TestGetSlugFrom(t *testing.T) { - r := httptest.NewRequest("GET", "/posts/s", nil) - r.SetPathValue("slug", " SLUG ") - if s := GetSlugFrom(r); s != "slug" { - t.Fatalf("slug %s", s) - } -} - func TestGetPostsResponse(t *testing.T) { now := time.Now() p := database.Post{ diff --git a/handler/payload/posts_slug_test.go b/handler/payload/posts_slug_test.go new file mode 100644 index 00000000..7c1e464c --- /dev/null +++ b/handler/payload/posts_slug_test.go @@ -0,0 +1,14 @@ +package payload + +import ( + "net/http/httptest" + "testing" +) + +func TestGetSlugFrom(t *testing.T) { + r := httptest.NewRequest("GET", "/posts/s", nil) + r.SetPathValue("slug", " SLUG ") + if s := GetSlugFrom(r); s != "slug" { + t.Fatalf("slug %s", s) + } +} diff --git a/handler/payload/profile_test.go b/handler/payload/profile_test.go new file mode 100644 index 00000000..e7d0d358 --- /dev/null +++ b/handler/payload/profile_test.go @@ -0,0 +1,17 @@ +package payload + +import ( + "encoding/json" + "testing" +) + +func TestProfileResponseJSON(t *testing.T) { + body := []byte(`{"version":"v1","data":{"nickname":"n","handle":"h","name":"nm","email":"e","profession":"p","skills":[{"uuid":"u","percentage":1,"item":"i","description":"d"}]}}`) + var res ProfileResponse + if err := json.Unmarshal(body, &res); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if res.Version != "v1" || res.Data.Handle != "h" || len(res.Data.Skills) != 1 || res.Data.Skills[0].Uuid != "u" { + t.Fatalf("unexpected response: %+v", res) + } +} diff --git a/handler/payload/projects_test.go b/handler/payload/projects_test.go new file mode 100644 index 00000000..f420d72d --- /dev/null +++ b/handler/payload/projects_test.go @@ -0,0 +1,17 @@ +package payload + +import ( + "encoding/json" + "testing" +) + +func TestProjectsResponseJSON(t *testing.T) { + body := []byte(`{"version":"v1","data":[{"uuid":"u","language":"l","title":"t","excerpt":"e","url":"u","icon":"i","is_open_source":true,"created_at":"c","updated_at":"up"}]}`) + var res ProjectsResponse + if err := json.Unmarshal(body, &res); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if res.Version != "v1" || len(res.Data) != 1 || !res.Data[0].IsOpenSource { + t.Fatalf("unexpected response: %+v", res) + } +} diff --git a/handler/payload/recommendations_test.go b/handler/payload/recommendations_test.go new file mode 100644 index 00000000..ae90e733 --- /dev/null +++ b/handler/payload/recommendations_test.go @@ -0,0 +1,17 @@ +package payload + +import ( + "encoding/json" + "testing" +) + +func TestRecommendationsResponseJSON(t *testing.T) { + body := []byte(`{"version":"v1","data":[{"uuid":"u","relation":"r","text":"t","created_at":"c","updated_at":"u","person":{"avatar":"a","full_name":"f","company":"co","designation":"d"}}]}`) + var res RecommendationsResponse + if err := json.Unmarshal(body, &res); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if res.Version != "v1" || len(res.Data) != 1 || res.Data[0].Person.FullName != "f" { + t.Fatalf("unexpected response: %+v", res) + } +} diff --git a/handler/payload/social_test.go b/handler/payload/social_test.go new file mode 100644 index 00000000..9926b7a3 --- /dev/null +++ b/handler/payload/social_test.go @@ -0,0 +1,17 @@ +package payload + +import ( + "encoding/json" + "testing" +) + +func TestSocialResponseJSON(t *testing.T) { + body := []byte(`{"version":"v1","data":[{"uuid":"u","handle":"h","url":"u","description":"d","name":"n"}]}`) + var res SocialResponse + if err := json.Unmarshal(body, &res); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if res.Version != "v1" || len(res.Data) != 1 || res.Data[0].Name != "n" { + t.Fatalf("unexpected response: %+v", res) + } +} diff --git a/handler/payload/tags_test.go b/handler/payload/tags_test.go new file mode 100644 index 00000000..6efe8a92 --- /dev/null +++ b/handler/payload/tags_test.go @@ -0,0 +1,15 @@ +package payload + +import ( + "testing" + + "github.com/oullin/database" +) + +func TestGetTagsResponse(t *testing.T) { + tags := []database.Tag{{UUID: "1", Name: "n", Slug: "s", Description: "d"}} + r := GetTagsResponse(tags) + if len(r) != 1 || r[0].Slug != "s" { + t.Fatalf("unexpected %#v", r) + } +} diff --git a/handler/payload/talks_test.go b/handler/payload/talks_test.go new file mode 100644 index 00000000..450fb1a4 --- /dev/null +++ b/handler/payload/talks_test.go @@ -0,0 +1,17 @@ +package payload + +import ( + "encoding/json" + "testing" +) + +func TestTalksResponseJSON(t *testing.T) { + body := []byte(`{"version":"v1","data":[{"uuid":"u","title":"t","subject":"s","location":"l","url":"u","photo":"p","created_at":"c","updated_at":"up"}]}`) + var res TalksResponse + if err := json.Unmarshal(body, &res); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if res.Version != "v1" || len(res.Data) != 1 || res.Data[0].Title != "t" { + t.Fatalf("unexpected response: %+v", res) + } +} diff --git a/handler/payload/users_test.go b/handler/payload/users_test.go new file mode 100644 index 00000000..bcafb3f8 --- /dev/null +++ b/handler/payload/users_test.go @@ -0,0 +1,17 @@ +package payload + +import ( + "encoding/json" + "testing" +) + +func TestUserResponseJSON(t *testing.T) { + body := []byte(`{"uuid":"u","first_name":"f","last_name":"l","username":"un","display_name":"dn","bio":"b","picture_file_name":"p","profile_picture_url":"pu","is_admin":true}`) + var res UserResponse + if err := json.Unmarshal(body, &res); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if res.UUID != "u" || res.FirstName != "f" || !res.IsAdmin { + t.Fatalf("unexpected response: %+v", res) + } +} diff --git a/handler/posts.go b/handler/posts.go index c1fbeb60..e6866f45 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -3,9 +3,8 @@ package handler import ( "encoding/json" "fmt" - "github.com/oullin/database" + "github.com/oullin/database/repository" "github.com/oullin/database/repository/pagination" - "github.com/oullin/database/repository/queries" "github.com/oullin/handler/paginate" "github.com/oullin/handler/payload" "github.com/oullin/pkg" @@ -14,17 +13,12 @@ import ( baseHttp "net/http" ) -type postsRepo interface { - GetAll(queries.PostFilters, pagination.Paginate) (*pagination.Pagination[database.Post], error) - FindBy(slug string) *database.Post -} - type PostsHandler struct { - Posts postsRepo + Posts *repository.Posts } -func MakePostsHandler(posts postsRepo) PostsHandler { - return PostsHandler{Posts: posts} +func MakePostsHandler(repo *repository.Posts) PostsHandler { + return PostsHandler{Posts: repo} } func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { diff --git a/handler/posts_test.go b/handler/posts_test.go new file mode 100644 index 00000000..1aca2e9e --- /dev/null +++ b/handler/posts_test.go @@ -0,0 +1,94 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + "github.com/oullin/database" + "github.com/oullin/database/repository" + "github.com/oullin/database/repository/pagination" + "github.com/oullin/handler/payload" + handlertests "github.com/oullin/handler/tests" +) + +func TestPostsHandlerIndex_ParseError(t *testing.T) { + h := PostsHandler{Posts: &repository.Posts{}} + badReq := httptest.NewRequest("POST", "/posts", bytes.NewReader([]byte("{"))) + rec := httptest.NewRecorder() + if h.Index(rec, badReq) == nil { + t.Fatalf("expected parse error") + } +} + +func TestPostsHandlerShow_MissingSlug(t *testing.T) { + h := PostsHandler{Posts: &repository.Posts{}} + req := httptest.NewRequest("GET", "/posts/", nil) + rec := httptest.NewRecorder() + if h.Show(rec, req) == nil { + t.Fatalf("expected bad request") + } +} + +func TestPostsHandlerIndex_Success(t *testing.T) { + conn, author := handlertests.MakeTestDB(t) + published := time.Now() + post := database.Post{UUID: uuid.NewString(), AuthorID: author.ID, Slug: "hello", Title: "Hello", Excerpt: "Ex", Content: "Body", PublishedAt: &published} + if err := conn.Sql().Create(&post).Error; err != nil { + t.Fatalf("create post: %v", err) + } + + h := MakePostsHandler(&repository.Posts{DB: conn}) + + req := httptest.NewRequest("POST", "/posts", bytes.NewReader([]byte("{}"))) + rec := httptest.NewRecorder() + + if err := h.Index(rec, req); err != nil { + t.Fatalf("index err: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + + var resp pagination.Pagination[payload.PostResponse] + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Data) != 1 || resp.Data[0].Slug != "hello" { + t.Fatalf("unexpected data: %+v", resp.Data) + } +} + +func TestPostsHandlerShow_Success(t *testing.T) { + conn, author := handlertests.MakeTestDB(t) + published := time.Now() + post := database.Post{UUID: uuid.NewString(), AuthorID: author.ID, Slug: "hello", Title: "Hello", Excerpt: "Ex", Content: "Body", PublishedAt: &published} + if err := conn.Sql().Create(&post).Error; err != nil { + t.Fatalf("create post: %v", err) + } + + h := MakePostsHandler(&repository.Posts{DB: conn}) + + req := httptest.NewRequest("GET", "/posts/hello", nil) + req.SetPathValue("slug", "hello") + rec := httptest.NewRecorder() + + if err := h.Show(rec, req); err != nil { + t.Fatalf("show err: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + + var resp payload.PostResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Slug != "hello" { + t.Fatalf("unexpected slug: %s", resp.Slug) + } +} diff --git a/handler/profile_test.go b/handler/profile_test.go new file mode 100644 index 00000000..e1f7e059 --- /dev/null +++ b/handler/profile_test.go @@ -0,0 +1,12 @@ +package handler + +import "testing" + +func TestProfileHandler(t *testing.T) { + runFileHandlerTest(t, fileHandlerTestCase{ + make: func(f string) fileHandler { return MakeProfileHandler(f) }, + endpoint: "/profile", + data: map[string]string{"nickname": "nick"}, + assert: assertNicknameNick, + }) +} diff --git a/handler/projects_test.go b/handler/projects_test.go new file mode 100644 index 00000000..93897e9a --- /dev/null +++ b/handler/projects_test.go @@ -0,0 +1,12 @@ +package handler + +import "testing" + +func TestProjectsHandler(t *testing.T) { + runFileHandlerTest(t, fileHandlerTestCase{ + make: func(f string) fileHandler { return MakeProjectsHandler(f) }, + endpoint: "/projects", + data: []map[string]string{{"uuid": "1"}}, + assert: assertArrayUUID1, + }) +} diff --git a/handler/recommendations_test.go b/handler/recommendations_test.go new file mode 100644 index 00000000..7c04024d --- /dev/null +++ b/handler/recommendations_test.go @@ -0,0 +1,12 @@ +package handler + +import "testing" + +func TestRecommendationsHandler(t *testing.T) { + runFileHandlerTest(t, fileHandlerTestCase{ + make: func(f string) fileHandler { return MakeRecommendationsHandler(f) }, + endpoint: "/recommendations", + data: []map[string]string{{"uuid": "1"}}, + assert: assertArrayUUID1, + }) +} diff --git a/handler/social_test.go b/handler/social_test.go new file mode 100644 index 00000000..f875fd66 --- /dev/null +++ b/handler/social_test.go @@ -0,0 +1,12 @@ +package handler + +import "testing" + +func TestSocialHandler(t *testing.T) { + runFileHandlerTest(t, fileHandlerTestCase{ + make: func(f string) fileHandler { return MakeSocialHandler(f) }, + endpoint: "/social", + data: []map[string]string{{"uuid": "1"}}, + assert: assertArrayUUID1, + }) +} diff --git a/handler/talks_test.go b/handler/talks_test.go new file mode 100644 index 00000000..9c19e716 --- /dev/null +++ b/handler/talks_test.go @@ -0,0 +1,12 @@ +package handler + +import "testing" + +func TestTalksHandler(t *testing.T) { + runFileHandlerTest(t, fileHandlerTestCase{ + make: func(f string) fileHandler { return MakeTalksHandler(f) }, + endpoint: "/talks", + data: []map[string]string{{"uuid": "1"}}, + assert: assertArrayUUID1, + }) +} diff --git a/handler/tests/db.go b/handler/tests/db.go new file mode 100644 index 00000000..74d6d47f --- /dev/null +++ b/handler/tests/db.go @@ -0,0 +1,75 @@ +package handlertests + +import ( + "context" + "os/exec" + "testing" + + "github.com/google/uuid" + "github.com/oullin/database" + "github.com/oullin/env" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" +) + +// MakeTestDB starts a PostgreSQL test container, runs migrations, and seeds a default user. +// It returns the database connection and the created user. +func MakeTestDB(t *testing.T) (*database.Connection, database.User) { + t.Helper() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not installed") + } + + ctx := context.Background() + pg, err := postgres.RunContainer(ctx, + testcontainers.WithImage("postgres:16-alpine"), + postgres.WithDatabase("testdb"), + postgres.WithUsername("test"), + postgres.WithPassword("secret"), + postgres.BasicWaitStrategies(), + ) + if err != nil { + t.Fatalf("container run err: %v", err) + } + t.Cleanup(func() { pg.Terminate(ctx) }) + + host, err := pg.Host(ctx) + if err != nil { + t.Fatalf("host err: %v", err) + } + port, err := pg.MappedPort(ctx, "5432/tcp") + if err != nil { + t.Fatalf("port err: %v", err) + } + + e := &env.Environment{ + DB: env.DBEnvironment{ + UserName: "test", + UserPassword: "secret", + DatabaseName: "testdb", + Port: port.Int(), + Host: host, + DriverName: database.DriverName, + SSLMode: "disable", + TimeZone: "UTC", + }, + } + + conn, err := database.MakeConnection(e) + if err != nil { + t.Fatalf("make connection: %v", err) + } + t.Cleanup(func() { conn.Close() }) + + if err := conn.Sql().AutoMigrate(&database.User{}, &database.Post{}, &database.Category{}, &database.Tag{}, &database.PostCategory{}, &database.PostTag{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + author := database.User{UUID: uuid.NewString(), Username: "user", FirstName: "F", LastName: "L", Email: "u@example.com", PasswordHash: "x"} + if err := conn.Sql().Create(&author).Error; err != nil { + t.Fatalf("create user: %v", err) + } + + return conn, author +} diff --git a/handler/tests/helpers.go b/handler/tests/helpers.go new file mode 100644 index 00000000..2f8d8482 --- /dev/null +++ b/handler/tests/helpers.go @@ -0,0 +1,25 @@ +package handlertests + +import ( + "encoding/json" + "os" + "testing" +) + +type TestEnvelope struct { + Version string `json:"version"` + Data interface{} `json:"data"` +} + +func WriteJSON(t *testing.T, v interface{}) string { + t.Helper() + f, err := os.CreateTemp("", "data.json") + if err != nil { + t.Fatalf("tmp: %v", err) + } + if err := json.NewEncoder(f).Encode(v); err != nil { + t.Fatalf("encode: %v", err) + } + f.Close() + return f.Name() +} diff --git a/handler/tests/helpers_test.go b/handler/tests/helpers_test.go new file mode 100644 index 00000000..ce21dd2f --- /dev/null +++ b/handler/tests/helpers_test.go @@ -0,0 +1,14 @@ +package handlertests + +import ( + "os" + "testing" +) + +func TestWriteJSON(t *testing.T) { + file := WriteJSON(t, TestEnvelope{Version: "v1"}) + if file == "" { + t.Fatalf("expected file path") + } + os.Remove(file) +}