diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e5f47abb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +COPY go.mod ./ +COPY go.sum ./ +RUN go mod download +COPY . . + +RUN go build -o favourites main.go + +# Runtime stage +FROM alpine:3.22 +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ +COPY --from=builder /app/favourites . + +EXPOSE 8080 +CMD ["./favourites"] diff --git a/README.md b/README.md index 3e004639..7616c6d0 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,121 @@ -# GlobalWebIndex Engineering Challenge +# User Favourites Service -## Introduction +## Scope +A web server that lets users manage their favourite assets including charts, insights and audiences. +Supports listing, adding, removing and editing favourites. -This challenge is designed to give you the opportunity to demonstrate your abilities as a software engineer and specifically your knowledge of the Go language. +## How to Run -On the surface the challenge is trivial to solve, however you should choose to add features or capabilities which you feel demonstrate your skills and knowledge the best. For example, you could choose to optimise for performance and concurrency, you could choose to add a robust security layer or ensure your application is highly available. Or all of these. +### Run locally +To build and run the program: +```bash +go run main.go +``` +Or, if you want to build first: +```bash +go build -o favourites +./favourites +``` -Of course, usually we would choose to solve any given requirement with the simplest possible solution, however that is not the spirit of this challenge. +### Run with Docker +```bash +docker build -t favourites . +docker run --rm -p 8080:8080 favourites +``` -## Challenge +## How to Test +Tests cover basic functionality and a few error scenarios. -Let's say that in GWI platform all of our users have access to a huge list of assets. We want our users to have a peronal list of favourites, meaning assets that favourite or “star” so that they have them in their frontpage dashboard for quick access. An asset can be one the following -* Chart (that has a small title, axes titles and data) -* Insight (a small piece of text that provides some insight into a topic, e.g. "40% of millenials spend more than 3hours on social media daily") -* Audience (which is a series of characteristics, for that exercise lets focus on gender (Male, Female), birth country, age groups, hours spent daily on social media, number of purchases last month) -e.g. Males from 24-35 that spent more than 3 hours on social media daily. +Run them with: +```bash +go test ./... +``` -Build a web server which has some endpoint to receive a user id and return a list of all the user’s favourites. Also we want endpoints that would add an asset to favourites, remove it, or edit its description. Assets obviously can share some common attributes (like their description) but they also have completely different structure and data. It’s up to you to decide the structure and we are not looking for something overly complex here (especially for the cases of audiences). There is no need to have/deploy/create an actual database although we would like to discuss about storage options and data representations. +## Usage examples +- Add a favourite +```bash +curl -X POST -H "Content-Type: application/json" \ + -d '{"assetId":"chart-42", "assetType":"chart", "description":"Top sales", "assetData":{"title":"Sales Q4","axes":["month","revenue"]}}' \ + http://localhost:8080/favourites/user123 +``` +`Response 201, with created entry including generated id` -Note that users have no limit on how many assets they want on their favourites so your service will need to provide a reasonable response time. +``` +{ + "id": "fav-1", + "userId": "user123", + "assetId": "chart-42", + "assetType": "chart", + "description": "Top sales", + "assetData": { "title": "Sales Q4", "axes": ["month","revenue"] }, + "createdAt": "2025-09-11T17:18:53.9766385+03:00", + "updatedAt": "2025-09-11T17:18:53.9766385+03:00" +} +``` -A working server application with functional API is required, along with a clear readme.md. Useful and passing tests would be also be viewed favourably +- List favourites for a user +```bash +curl -s http://localhost:8080/favourites/user123 | jq +``` +`Response 200` -It is appreciated, though not required, if a Dockerfile is included. + ``` + [ + { + "id": "fav-1", + "userId": "user123", + "assetId": "chart-42", + "assetType": "chart", + "description": "Top sales", + "assetData": { "title": "Sales Q4", "axes": ["month","revenue"] }, + "createdAt": "2025-09-11T17:18:53.9766385+03:00", + "updatedAt": "2025-09-11T17:18:53.9766385+03:00" + } + ] + ``` -## Submission +- Update a favourite +```bash +curl -X PUT -H "Content-Type: application/json" \ + -d '{"assetId":"chart-21", "assetType":"chart", "description":"Sales frequency", "assetData":{"title":"Annual retention","axes":["month","invoice_count"]}}' \ + http://localhost:8080/favourites/user123/fav-1 +``` -Just create a fork from the current repo and send it to us! +- Delete a favourite +```bash +curl -X DELETE http://localhost:8080/favourites/user123/fav-1 +``` -Good luck, potential colleague! +## Assumptions +- REST API endpoints to fetch, add, remove and update assets in favourites list. +- JSON request/response with lower-camel JSON keys (id, userId, assetId, assetType, description, assetData, createdAt, updatedAt). +- In-memory store for the challenge purposes. No data store persistence present. Stored data are lost on restart. +- Tests cover store methods, HTTP handlers, validation, conflicts and error paths (including with a mock store). + +## Data model +- This service stores references to existing platform assets. The source of truth for assets lives upstream. +- `description` is a user-provided label for the favourite and may be empty. +- `assetData` stores the upstream asset JSON as-is (flexible); on updates, clients send the full updated asset JSON. The service does not validate against upstream schemas. +- On reads, the service returns stored favourites. If upstream assets are missing, clients are expected to avoid selecting them; future versions may exclude or flag missing assets when an upstream lookup is enabled. + +## CI/CD (suggested checks) +In a CI pipeline, you can run basic quality gates before building and deploying: + +```bash +# Format code +go fmt ./... + +# Static analysis +go vet ./... +staticcheck ./... + +# Run tests +go test ./... +``` + +## Next steps +- Solution is based on an in-memory store which makes storage ephemeral and works for a single instance only. Persistent storage should be used, for instance Postgres JSONB, or adopt a platform-wide storage solution. +- Make use of request context for better efficiency, especially when a persistent store is integrated. +- Coordinate upstream contracts for asset verification and potential reconciliation (exclusion/flags for missing assets) once teams align. +- Performance: caching per user with Redis since it is shown on the front page. +- Performance: pagination for very large lists. diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..5598cb4c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/parath/platform-go-challenge + +go 1.25 + +require github.com/gorilla/mux v1.8.1 diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..71283374 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/internal/favourites/model.go b/internal/favourites/model.go new file mode 100644 index 00000000..aaed42b9 --- /dev/null +++ b/internal/favourites/model.go @@ -0,0 +1,29 @@ +package favourites + +import ( + "encoding/json" + "time" +) + +type AssetType string + +const ( + AssetTypeChart AssetType = "chart" + AssetTypeInsight AssetType = "insight" + AssetTypeAudience AssetType = "audience" +) + +// Favourite represents a user's favourite asset. +// Common attributes (ID, UserID, AssetID, AssetType, Description, CreatedAt) +// are top-level fields. Asset-specific data (like chart axes or audience criteria) +// is stored as free-form JSON in AssetData for flexibility. +type Favourite struct { + ID string `json:"id"` + UserID string `json:"userId"` + AssetID string `json:"assetId"` + AssetType AssetType `json:"assetType"` + Description string `json:"description,omitempty"` + AssetData json.RawMessage `json:"assetData"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/internal/favourites/store.go b/internal/favourites/store.go new file mode 100644 index 00000000..f5614bc9 --- /dev/null +++ b/internal/favourites/store.go @@ -0,0 +1,102 @@ +package favourites + +import ( + "errors" + "fmt" + "sync" + "time" +) + +// Store defines the behaviour for managing favourites. +type Store interface { + AddFavourite(Favourite) (Favourite, error) + GetFavourites(userID string) ([]Favourite, error) + UpdateFavourite(userID, favouriteID string, update Favourite) (Favourite, error) + DeleteFavourite(userID, favouriteID string) error + NextFavouriteID() string +} + +// InMemoryStore stores favourites in memory per user +type InMemoryStore struct { + mu sync.RWMutex + store map[string][]Favourite + nextID int +} + +var ErrNotFound = errors.New("favourite not found") +var ErrConflict = errors.New("favourite already exists for user and asset") + +func NewInMemoryStore() *InMemoryStore { + return &InMemoryStore{store: make(map[string][]Favourite)} +} + +func (s *InMemoryStore) AddFavourite(f Favourite) (Favourite, error) { + s.mu.Lock() + defer s.mu.Unlock() + // Enforces uniqueness on (userId, assetId). + for i := range s.store[f.UserID] { + if s.store[f.UserID][i].AssetID == f.AssetID { + return Favourite{}, ErrConflict + } + } + f.CreatedAt = time.Now() + f.UpdatedAt = f.CreatedAt + s.store[f.UserID] = append(s.store[f.UserID], f) + return f, nil +} + +func (s *InMemoryStore) GetFavourites(userID string) ([]Favourite, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]Favourite(nil), s.store[userID]...), nil +} + +func (s *InMemoryStore) UpdateFavourite(userID, favouriteID string, update Favourite) (Favourite, error) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.store[userID] + for i := range list { + if list[i].ID == favouriteID { + existing := list[i] + // if assetId is changing, ensure no other fav uses that assetId for this user + if existing.AssetID != update.AssetID { + for j := range list { + if j != i && list[j].AssetID == update.AssetID { + return Favourite{}, ErrConflict + } + } + } + existing.AssetID = update.AssetID + existing.AssetType = update.AssetType + existing.Description = update.Description + existing.AssetData = update.AssetData + existing.UpdatedAt = time.Now() + list[i] = existing + s.store[userID] = list + return existing, nil + } + } + return Favourite{}, ErrNotFound +} + +func (s *InMemoryStore) DeleteFavourite(userID, favouriteID string) error { + s.mu.Lock() + defer s.mu.Unlock() + list := s.store[userID] + for i := range list { + if list[i].ID == favouriteID { + list[i] = list[len(list)-1] + list = list[:len(list)-1] + s.store[userID] = list + return nil + } + } + return ErrNotFound +} + +func (s *InMemoryStore) NextFavouriteID() string { + s.mu.Lock() + defer s.mu.Unlock() + s.nextID++ + return fmt.Sprintf("fav-%d", s.nextID) +} diff --git a/internal/favourites/store_test.go b/internal/favourites/store_test.go new file mode 100644 index 00000000..5186267e --- /dev/null +++ b/internal/favourites/store_test.go @@ -0,0 +1,103 @@ +package favourites + +import ( + "encoding/json" + "testing" +) + +func TestAddAndGetFavourites(t *testing.T) { + store := NewInMemoryStore() + + fav1 := Favourite{ + ID: store.NextFavouriteID(), + UserID: "user-1", + AssetID: "chart-1", + AssetType: "chart", + AssetData: json.RawMessage(`{"title":"Chart-1"}`), + Description: "First chart", + } + fav2 := Favourite{ + ID: store.NextFavouriteID(), + UserID: "user-1", + AssetID: "chart-2", + AssetType: "chart", + AssetData: json.RawMessage(`{"title":"Chart-2"}`), + Description: "Second chart", + } + + if _, err := store.AddFavourite(fav1); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := store.AddFavourite(fav2); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + favs, err := store.GetFavourites("user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(favs) != 2 { + t.Fatalf("expected 2 favourites, got %d", len(favs)) + } +} + +func TestUpdateFavourite(t *testing.T) { + store := NewInMemoryStore() + fav := Favourite{ + ID: store.NextFavouriteID(), + UserID: "user-1", + AssetID: "chart-1", + AssetType: "chart", + AssetData: json.RawMessage(`{"title":"Chart-1"}`), + Description: "Old", + } + _, _ = store.AddFavourite(fav) + + update := Favourite{ + AssetID: "chart-99", + AssetType: "chart", + AssetData: json.RawMessage(`{"title":"Updated"}`), + Description: "New", + } + + updated, err := store.UpdateFavourite("user-1", fav.ID, update) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if updated.Description != "New" || updated.AssetID != "chart-99" { + t.Fatalf("update failed, got %+v", updated) + } +} + +func TestUpdateFavourite_NotFound(t *testing.T) { + store := NewInMemoryStore() + update := Favourite{AssetID: "chart-1"} + _, err := store.UpdateFavourite("user-1", "does-not-exist", update) + if err == nil { + t.Fatal("expected error, got nil") + } + if err != ErrNotFound { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestDeleteFavourite(t *testing.T) { + store := NewInMemoryStore() + fav := Favourite{ + ID: store.NextFavouriteID(), + UserID: "user-1", + } + _, _ = store.AddFavourite(fav) + + if err := store.DeleteFavourite("user-1", fav.ID); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Deleting again should fail + if err := store.DeleteFavourite("user-1", fav.ID); err == nil { + t.Fatal("expected error, got nil") + } + if err := store.DeleteFavourite("user-1", fav.ID); err != ErrNotFound { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go new file mode 100644 index 00000000..dad7511b --- /dev/null +++ b/internal/httpapi/server.go @@ -0,0 +1,178 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + + "github.com/gorilla/mux" + "github.com/parath/platform-go-challenge/internal/favourites" +) + +type Server struct { + store favourites.Store +} + +type ErrorCode string + +const ( + CodeInvalidBody ErrorCode = "invalid_body" + CodeStoreError ErrorCode = "store_error" + CodeNotFound ErrorCode = "not_found" + CodeConflict ErrorCode = "conflict" +) + +type apiError struct { + Error string `json:"error"` + Code ErrorCode `json:"code,omitempty"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// logRequest writes a simple, consistent log line for responses (success or error). +// extras is an optional string with additional context like "status=... code=... msg=... userId=... favId=...". +func logRequest(r *http.Request, extras string) { + if extras != "" { + log.Printf("request: %s %s %s", r.Method, r.URL.Path, extras) + return + } + log.Printf("request: %s %s", r.Method, r.URL.Path) +} + +func writeError(w http.ResponseWriter, r *http.Request, status int, msg string, code ErrorCode) { + logRequest(r, fmt.Sprintf("status=%d code=%s msg=%s", status, code, msg)) + writeJSON(w, status, apiError{Error: msg, Code: code}) +} + +func writeOK(w http.ResponseWriter, r *http.Request, status int, v any, extras string) { + logRequest(r, fmt.Sprintf("status=%d ok %s", status, extras)) + writeJSON(w, status, v) +} + +func isValidAssetType(t favourites.AssetType) bool { + switch t { + case favourites.AssetTypeChart, favourites.AssetTypeInsight, favourites.AssetTypeAudience: + return true + default: + return false + } +} + +// This service stores a lightweight snapshot of the upstream asset, so we cap assetData +// around 100KB to prevent abuse. +func validateFavouriteInput(f favourites.Favourite) (string, ErrorCode) { + if f.AssetID == "" { + return "assetId is required", CodeInvalidBody + } + if !isValidAssetType(f.AssetType) { + return "invalid assetType", CodeInvalidBody + } + if len(f.AssetData) > 100*1024 { // ~100KB safety cap + return "assetData too large", CodeInvalidBody + } + if len(f.Description) > 512 { + return "description too long", CodeInvalidBody + } + return "", "" +} + +func NewServer(store favourites.Store) *mux.Router { + s := &Server{store: store} + r := mux.NewRouter() + r.HandleFunc("/favourites/{userId}", s.getFavouritesHandler).Methods("GET") + r.HandleFunc("/favourites/{userId}", s.addFavouriteHandler).Methods("POST") + r.HandleFunc("/favourites/{userId}/{id}", s.updateFavouriteHandler).Methods("PUT") + r.HandleFunc("/favourites/{userId}/{id}", s.deleteFavouriteHandler).Methods("DELETE") + return r +} + +// Handlers +func (s *Server) getFavouritesHandler(w http.ResponseWriter, r *http.Request) { + userID := mux.Vars(r)["userId"] + favs, err := s.store.GetFavourites(userID) + if err != nil { + writeError(w, r, http.StatusInternalServerError, "failed to get favourites", CodeStoreError) + return + } + + writeOK(w, r, http.StatusOK, favs, fmt.Sprintf("userId=%s", userID)) +} + +func (s *Server) addFavouriteHandler(w http.ResponseWriter, r *http.Request) { + userID := mux.Vars(r)["userId"] + var f favourites.Favourite + if err := json.NewDecoder(r.Body).Decode(&f); err != nil { + writeError(w, r, http.StatusBadRequest, "invalid request body", CodeInvalidBody) + return + } + if msg, code := validateFavouriteInput(f); msg != "" { + writeError(w, r, http.StatusBadRequest, msg, code) + return + } + f.UserID = userID + f.ID = s.store.NextFavouriteID() + created, err := s.store.AddFavourite(f) + if err != nil { + if errors.Is(err, favourites.ErrConflict) { + writeError(w, r, http.StatusConflict, "favourite already exists for user and asset", CodeConflict) + return + } + writeError(w, r, http.StatusInternalServerError, "failed to add favourite", CodeStoreError) + return + } + + writeOK(w, r, http.StatusCreated, created, fmt.Sprintf("userId=%s favId=%s", created.UserID, created.ID)) +} + +func (s *Server) updateFavouriteHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userId"] + favID := vars["id"] + var upd favourites.Favourite + if err := json.NewDecoder(r.Body).Decode(&upd); err != nil { + writeError(w, r, http.StatusBadRequest, "invalid request body", CodeInvalidBody) + return + } + if msg, code := validateFavouriteInput(upd); msg != "" { + writeError(w, r, http.StatusBadRequest, msg, code) + return + } + updated, err := s.store.UpdateFavourite(userID, favID, upd) + if err != nil { + if errors.Is(err, favourites.ErrNotFound) { + writeError(w, r, http.StatusNotFound, err.Error(), CodeNotFound) + return + } + if errors.Is(err, favourites.ErrConflict) { + writeError(w, r, http.StatusConflict, "favourite already exists for user and asset", CodeConflict) + return + } + writeError(w, r, http.StatusInternalServerError, "internal server error", CodeStoreError) + return + } + + writeOK(w, r, http.StatusOK, updated, fmt.Sprintf("userId=%s favId=%s", userID, favID)) +} + +func (s *Server) deleteFavouriteHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userId"] + favID := vars["id"] + err := s.store.DeleteFavourite(userID, favID) + if err != nil { + if errors.Is(err, favourites.ErrNotFound) { + writeError(w, r, http.StatusNotFound, err.Error(), CodeNotFound) + } else { + writeError(w, r, http.StatusInternalServerError, "internal server error", CodeStoreError) + } + return + } + logRequest(r, fmt.Sprintf("status=%d ok userId=%s favId=%s", http.StatusNoContent, userID, favID)) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go new file mode 100644 index 00000000..e7708ac3 --- /dev/null +++ b/internal/httpapi/server_test.go @@ -0,0 +1,288 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/parath/platform-go-challenge/internal/favourites" +) + +// --- MockStore for testing error paths --- +type MockStore struct { + Err error +} + +func (m *MockStore) AddFavourite(favourites.Favourite) (favourites.Favourite, error) { + return favourites.Favourite{}, m.Err +} +func (m *MockStore) GetFavourites(userID string) ([]favourites.Favourite, error) { return nil, m.Err } +func (m *MockStore) UpdateFavourite(userID, favouriteID string, update favourites.Favourite) (favourites.Favourite, error) { + return favourites.Favourite{}, m.Err +} +func (m *MockStore) DeleteFavourite(userID, favouriteID string) error { return m.Err } +func (m *MockStore) NextFavouriteID() string { return "fav-mock" } + +// --- Helpers for integration tests --- +func newTestServer() *mux.Router { return NewServer(favourites.NewInMemoryStore()) } + +func doRequest(t *testing.T, r http.Handler, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader io.Reader + if body != nil { + switch b := body.(type) { + case []byte: + reader = bytes.NewReader(b) + case string: + reader = bytes.NewBufferString(b) + default: + data, err := json.Marshal(b) + if err != nil { + t.Fatalf("failed to marshal body: %v", err) + } + reader = bytes.NewReader(data) + } + } + req := httptest.NewRequest(method, path, reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + return rr +} + +func createFavourite(t *testing.T, r http.Handler, userID string, body map[string]any) favourites.Favourite { + t.Helper() + rr := doRequest(t, r, http.MethodPost, "/favourites/"+userID, body) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + var created favourites.Favourite + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatalf("invalid json: %v", err) + } + return created +} + +// --- Tests --- +func TestGetFavourites_Empty(t *testing.T) { + r := newTestServer() + rr := doRequest(t, r, http.MethodGet, "/favourites/user-1", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var favs []favourites.Favourite + if err := json.Unmarshal(rr.Body.Bytes(), &favs); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(favs) != 0 { + t.Fatalf("expected empty list, got %d", len(favs)) + } +} + +func TestPostFavourite_CreateAndList(t *testing.T) { + r := newTestServer() + payload := map[string]any{ + "assetId": "chart-42", + "assetType": "chart", + "description": "Top sales", + "assetData": map[string]any{"title": "Sales Q4"}, + } + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + var created favourites.Favourite + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatalf("invalid json: %v", err) + } + if created.ID == "" || created.UserID != "user-1" { + t.Fatalf("unexpected created favourite: %+v", created) + } + // List + rr = doRequest(t, r, http.MethodGet, "/favourites/user-1", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } +} + +func TestPutFavourite_Update(t *testing.T) { + r := newTestServer() + created := createFavourite(t, r, "user-1", map[string]any{ + "assetId": "chart-42", + "assetType": "chart", + "description": "Top sales", + }) + + upd := favourites.Favourite{ + AssetID: "chart-99", + AssetType: "chart", + Description: "Updated", + CreatedAt: time.Now(), + } + path := "/favourites/" + created.UserID + "/" + created.ID + rr := doRequest(t, r, http.MethodPut, path, upd) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } +} + +func TestPutFavourite_NotFound(t *testing.T) { + r := newTestServer() + upd := map[string]any{"assetId": "chart-1", "assetType": "chart"} + rr := doRequest(t, r, http.MethodPut, "/favourites/user-1/fav-999", upd) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json, got %s", ct) + } + var errBody map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil { + t.Fatalf("invalid json error body: %v", err) + } + if _, ok := errBody["error"]; !ok { + t.Fatalf("expected error field in body, got %v", errBody) + } +} + +func TestDeleteFavourite(t *testing.T) { + r := newTestServer() + created := createFavourite(t, r, "user-1", map[string]any{"assetId": "a1", "assetType": "chart"}) + rr := doRequest(t, r, http.MethodDelete, "/favourites/"+created.UserID+"/"+created.ID, nil) + if rr.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", rr.Code) + } + // Delete again -> 404 + rr = doRequest(t, r, http.MethodDelete, "/favourites/"+created.UserID+"/"+created.ID, nil) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json, got %s", ct) + } +} + +func TestPostFavourite_InvalidBody(t *testing.T) { + r := newTestServer() + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", "not-json") + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json, got %s", ct) + } + var errBody map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil { + t.Fatalf("invalid json error body: %v", err) + } +} + +// --- Error path tests with MockStore --- +func TestGetFavourites_StoreError(t *testing.T) { + badStore := &MockStore{Err: errors.New("boom")} + r := NewServer(badStore) + + rr := doRequest(t, r, http.MethodGet, "/favourites/user-1", nil) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json, got %s", ct) + } +} + +func TestAddFavourite_StoreError(t *testing.T) { + badStore := &MockStore{Err: errors.New("boom")} + r := NewServer(badStore) + payload := map[string]any{"assetId": "a1", "assetType": "chart"} + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json, got %s", ct) + } +} + +// --- Conflict tests --- +func TestPostFavourite_DuplicateConflict(t *testing.T) { + r := newTestServer() + payload := map[string]any{"assetId": "a1", "assetType": "chart"} + _ = doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", rr.Code) + } +} + +// --- Validation tests --- +func TestPostFavourite_Validation_MissingAssetId(t *testing.T) { + r := newTestServer() + payload := map[string]any{ + "assetType": "chart", + "assetData": map[string]any{"title": "X"}, + } + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} + +func TestPostFavourite_Validation_InvalidAssetType(t *testing.T) { + r := newTestServer() + payload := map[string]any{ + "assetId": "chart-1", + "assetType": "wrong", + } + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} + +func TestPutFavourite_ChangeToExistingAsset_Conflict(t *testing.T) { + r := newTestServer() + fav1 := createFavourite(t, r, "user-1", map[string]any{"assetId": "a1", "assetType": "chart"}) + _ = createFavourite(t, r, "user-1", map[string]any{"assetId": "a2", "assetType": "chart"}) + upd := favourites.Favourite{AssetID: "a2", AssetType: "chart"} + path := "/favourites/" + fav1.UserID + "/" + fav1.ID + rr := doRequest(t, r, http.MethodPut, path, upd) + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", rr.Code) + } +} + +func TestPostFavourite_Validation_LongDescription(t *testing.T) { + r := newTestServer() + payload := map[string]any{ + "assetId": "chart-1", + "assetType": "chart", + "description": string(make([]byte, 600)), // >512 chars + } + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} + +func TestPostFavourite_Validation_AssetDataTooLarge(t *testing.T) { + r := newTestServer() + huge := make([]byte, 200*1024) // 200KB > 100KB limit + payload := map[string]any{ + "assetId": "chart-2", + "assetType": "chart", + "assetData": huge, + } + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} diff --git a/main.go b/main.go new file mode 100644 index 00000000..dd06afb8 --- /dev/null +++ b/main.go @@ -0,0 +1,18 @@ +/* +Favourites is a simple web server that manages a per-user list of favourite assets. +*/ +package main + +import ( + "log" + "net/http" + + "github.com/parath/platform-go-challenge/internal/favourites" + "github.com/parath/platform-go-challenge/internal/httpapi" +) + +func main() { + r := httpapi.NewServer(favourites.NewInMemoryStore()) + log.Println("Server listening on :8080") + log.Fatal(http.ListenAndServe(":8080", r)) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 00000000..8611ad06 --- /dev/null +++ b/main_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/parath/platform-go-challenge/internal/favourites" + "github.com/parath/platform-go-challenge/internal/httpapi" +) + +// Smoke test: verify that the main server wiring works +func TestMainServerWiring(t *testing.T) { + router := httpapi.NewServer(favourites.NewInMemoryStore()) + + req := httptest.NewRequest(http.MethodGet, "/favourites/smoke", nil) + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + + // We expect either 200 (empty list) or 500 if store fails, + // but never a panic or crash. + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 OK for empty user, got %d", rr.Code) + } +}