Initial commit
Go CI / build (push) Failing after 2m42s

feat: create basic server to manage google oauth, account, sessions, places, attributes and ratings.
This commit is contained in:
2025-09-19 02:43:04 -07:00
commit f1909da1ad
26 changed files with 2619 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
package api
import (
"net/http"
"git.pengzhan.dev/noteplace-server/internal/models"
"git.pengzhan.dev/noteplace-server/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// AttributesHandler handles HTTP requests for attributes.
type AttributesHandler struct {
Store *store.Store
}
// HandleCreateAttribute creates a new attribute.
func (h *AttributesHandler) HandleCreateAttribute(c *gin.Context) {
var newAttr models.Attribute
if err := c.ShouldBindJSON(&newAttr); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
// --- Validation ---
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not found in context"})
return
}
if user.(models.User).Role > models.TRUSTED {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not allowed to perform this action"})
return
}
if newAttr.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Name is a required field"})
return
}
switch newAttr.Scope {
case models.CATEGORY:
if newAttr.OwnerID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OwnerID (PlaceCategory) is required for CATEGORY attributes"})
return
}
case models.PLACE:
if newAttr.OwnerID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OwnerID (PlaceID) is required for PLACE attributes"})
return
}
if _, found := h.Store.GetPlaceByID(newAttr.OwnerID); !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "OwnerID (PlaceID) does not refer to a valid place"})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Scope must be either CATEGORY or PLACE"})
return
}
newAttr.ID = "attr-" + uuid.New().String()
// UserID for attribute creation is not specified in DESIGN.md, assuming it's not directly linked to the creator for now.
if err := h.Store.CreateAttribute(newAttr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save attribute"})
return
}
c.JSON(http.StatusCreated, newAttr)
}
+89
View File
@@ -0,0 +1,89 @@
package api
import (
"log"
"net/http"
"time"
"git.pengzhan.dev/noteplace-server/internal/auth"
"git.pengzhan.dev/noteplace-server/internal/models"
"git.pengzhan.dev/noteplace-server/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// AuthHandler handles the authentication API endpoints.
type AuthHandler struct {
Store *store.Store
Authenticator *auth.Authenticator
}
// HandleCliLogin provides the user with the URL to start the auth process.
func (h *AuthHandler) HandleCliLogin(c *gin.Context) {
authURL := h.Authenticator.GetAuthURL()
response := gin.H{
"message": `Please open the following URL in your browser to authorize the application.\nAfter authorizing, Google will provide a code. Paste it here to complete the login.\nExample: curl -X POST -d '{\"code\":\"YOUR_CODE_HERE\"}' http://localhost:3001/api/auth/google/cli/callback`,
"url": authURL,
}
c.JSON(http.StatusOK, response)
}
// HandleCliCallback exchanges the code for a token and creates a session.
func (h *AuthHandler) HandleCliCallback(c *gin.Context) {
var reqBody struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&reqBody); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
token, err := h.Authenticator.ExchangeCodeForToken(reqBody.Code)
if err != nil {
log.Printf("Failed to exchange code for token: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Failed to validate code"})
return
}
userInfo, err := h.Authenticator.GetUserInfo(token)
if err != nil {
log.Printf("Failed to get user info: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info from Google"})
return
}
// --- User and Session Logic ---
user, found := h.Store.GetUserByGoogleID(userInfo.GoogleID)
if !found {
userInfo.ID = "user-" + uuid.New().String()
userInfo.CreatedAt = time.Now().UTC().Format(time.RFC3339)
if err := h.Store.CreateUser(userInfo); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
user = userInfo
}
// Create a new session and save it to the store
sessionID := "session-" + uuid.New().String()
now := time.Now().UTC().Format(time.RFC3339)
newSession := models.Session{
ID: sessionID,
UserID: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
if err := h.Store.CreateSession(newSession); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Login successful!", "session_token": newSession.ID})
}
// HandleGetUser returns the current user based on the session token.
func (h *AuthHandler) HandleGetUser(c *gin.Context) {
user := c.MustGet("user").(models.User)
c.JSON(http.StatusOK, user)
}
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"net/http"
"git.pengzhan.dev/noteplace-server/internal/store"
"github.com/gin-gonic/gin"
)
// AuthMiddleware is a Gin middleware for authentication.
func AuthMiddleware(s *store.Store) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
c.Abort()
return
}
sessionToken := authHeader
// Assuming the token is directly the session token, not "Bearer <token>"
// If it's "Bearer <token>", you'd need to parse it:
// parts := strings.Split(authHeader, " ")
// if len(parts) != 2 || parts[0] != "Bearer" {
// c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
// c.Abort()
// return
// }
// sessionToken = parts[1]
session, found := s.GetSessionBySessionID(sessionToken)
if !found {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired session token"})
c.Abort()
return
}
user, found := s.GetUserByID(session.UserID)
if !found {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
c.Abort()
return
}
c.Set("user", user)
c.Next()
}
}
+94
View File
@@ -0,0 +1,94 @@
package api
import (
"net/http"
"time"
"git.pengzhan.dev/noteplace-server/internal/models"
"git.pengzhan.dev/noteplace-server/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// PlacesHandler handles HTTP requests for places.
type PlacesHandler struct {
Store *store.Store
}
// HandleGetPlaces returns a list of all places.
func (h *PlacesHandler) HandleGetPlaces(c *gin.Context) {
h.Store.Mu.RLock()
defer h.Store.Mu.RUnlock()
places := h.Store.Data.Places
c.JSON(http.StatusOK, places)
}
// HandleCreatePlace creates a new place.
func (h *PlacesHandler) HandleCreatePlace(c *gin.Context) {
var newPlace models.Place
if err := c.ShouldBindJSON(&newPlace); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if newPlace.Name == "" || newPlace.Category == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Name and Category are required fields"})
return
}
newPlace.ID = "place-" + uuid.New().String()
newPlace.CreatedAt = time.Now().UTC().Format(time.RFC3339)
// Extract UserID from context after authentication middleware
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not found in context"})
return
}
if user.(models.User).Role > models.TRUSTED {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not allowed to perform this action"})
return
}
if err := h.Store.CreatePlace(newPlace); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save place"})
return
}
c.JSON(http.StatusCreated, newPlace)
}
// HandleGetPlaceByID returns a single place by its ID.
func (h *PlacesHandler) HandleGetPlaceByID(c *gin.Context) {
id := c.Param("id")
place, found := h.Store.GetPlaceByID(id)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Place not found"})
return
}
c.JSON(http.StatusOK, place)
}
// HandleGetPlaceRatings returns all ratings for a specific place.
func (h *PlacesHandler) HandleGetPlaceRatings(c *gin.Context) {
placeID := c.Param("id")
if _, found := h.Store.GetPlaceByID(placeID); !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Place not found"})
return
}
ratings := h.Store.GetRatingsByPlaceID(placeID)
c.JSON(http.StatusOK, ratings)
}
// HandleGetPlaceAttributes returns all relevant attributes for a specific place.
func (h *PlacesHandler) HandleGetPlaceAttributes(c *gin.Context) {
placeID := c.Param("id")
place, found := h.Store.GetPlaceByID(placeID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Place not found"})
return
}
attributes := h.Store.GetAttributesByPlace(place)
c.JSON(http.StatusOK, attributes)
}
+124
View File
@@ -0,0 +1,124 @@
package api
import (
"net/http"
"time"
"git.pengzhan.dev/noteplace-server/internal/models"
"git.pengzhan.dev/noteplace-server/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// RatingsHandler handles HTTP requests for ratings.
type RatingsHandler struct {
Store *store.Store
}
// HandleCreateRating creates a new rating.
func (h *RatingsHandler) HandleCreateRating(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not found in context"})
return
}
var newRating models.Rating
if err := c.ShouldBindJSON(&newRating); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if newRating.PlaceID == "" || newRating.AttributeID == "" || newRating.Score < 1 || newRating.Score > 10 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: PlaceID, AttributeID, and Score (1-10) are required"})
return
}
newRating.ID = "rating-" + uuid.New().String()
newRating.CreatedAt = time.Now().UTC().Format(time.RFC3339)
newRating.UserID = user.(models.User).ID
if err := h.Store.CreateRating(newRating); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save rating"})
return
}
c.JSON(http.StatusCreated, newRating)
}
// HandleUpdateRating updates an existing rating.
func (h *RatingsHandler) HandleUpdateRating(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not found in context"})
return
}
id := c.Param("id")
var reqBody struct {
Score int `json:"score"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&reqBody); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if reqBody.Score < 1 || reqBody.Score > 10 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Score must be between 1 and 10"})
return
}
rating, found := h.Store.GetRatingByID(id)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Rating not found"})
return
}
if rating.UserID != user.(models.User).ID {
c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden: You can only update your own ratings"})
return
}
updatedRating, found := h.Store.UpdateRating(id, reqBody.Score, reqBody.Comment)
if !found {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update rating"})
return
}
c.JSON(http.StatusOK, updatedRating)
}
// HandleDeleteRating deletes a rating.
func (h *RatingsHandler) HandleDeleteRating(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not found in context"})
return
}
id := c.Param("id")
rating, found := h.Store.GetRatingByID(id)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Rating not found"})
return
}
if rating.UserID != user.(models.User).ID {
c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden: You can only delete your own ratings"})
return
}
if !h.Store.DeleteRating(id) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete rating"})
return
}
c.Status(http.StatusNoContent)
}
// HandleGetMyRatings gets all ratings for the current user.
func (h *RatingsHandler) HandleGetMyRatings(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not found in context"})
return
}
ratings := h.Store.GetRatingsByUserID(user.(models.User).ID)
c.JSON(http.StatusOK, ratings)
}
+83
View File
@@ -0,0 +1,83 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"git.pengzhan.dev/noteplace-server/internal/models"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
// Authenticator handles the OAuth2 flow.
type Authenticator struct {
Config *oauth2.Config
}
// NewAuthenticator creates a new Authenticator.
// It requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables.
func NewAuthenticator() (*Authenticator, error) {
clientID := os.Getenv("GOOGLE_CLIENT_ID")
clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
return nil, fmt.Errorf("GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set")
}
config := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: "urn:ietf:wg:oauth:2.0:oob", // Special redirect URI for desktop/CLI apps
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"},
Endpoint: google.Endpoint,
}
return &Authenticator{Config: config}, nil
}
// GetAuthURL generates the URL the user must visit to authorize the application.
func (a *Authenticator) GetAuthURL() string {
// We don't need a state for this simple CLI flow, but it's good practice for web apps.
return a.Config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
}
// ExchangeCodeForToken takes an authorization code and exchanges it for a token.
func (a *Authenticator) ExchangeCodeForToken(code string) (*oauth2.Token, error) {
return a.Config.Exchange(context.Background(), code)
}
// GetUserInfo uses the token to fetch the user's profile from Google.
func (a *Authenticator) GetUserInfo(token *oauth2.Token) (models.User, error) {
client := a.Config.Client(context.Background(), token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
return models.User{}, err
}
defer func() {
_ = resp.Body.Close()
}()
var userInfo struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
return models.User{}, err
}
log.Printf("Fetched user info from Google: ID=%s, Name=%s", userInfo.ID, userInfo.Name)
// We create a new User model from the Google info.
user := models.User{
GoogleID: userInfo.ID,
DisplayName: userInfo.Name,
Email: userInfo.Email,
}
return user, nil
}
+77
View File
@@ -0,0 +1,77 @@
package models
type RoleType int
const (
ADMIN RoleType = iota
TRUSTED
USER
)
// User represents an authenticated user.
type User struct {
ID string `json:"id"` // e.g., "user-google-123"
GoogleID string `json:"googleId"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Role RoleType `json:"role"`
CreatedAt string `json:"createdAt"`
}
type Session struct {
ID string `json:"id"`
UserID string `json:"userId"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// Place represents a physical location.
type Place struct {
ID string `json:"id"` // e.g., "place-001"
Name string `json:"name"`
Category string `json:"category"` // e.g., "Restaurant"
Address Address `json:"address"`
CreatedAt string `json:"createdAt"`
}
// Address is a sub-struct for Place.
type Address struct {
Street string `json:"street"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
Country string `json:"country"`
}
type Category struct {
ID string `json:"category"`
Name string `json:"name"`
}
type ScopeType int
const (
CATEGORY ScopeType = iota
PLACE
)
// Attribute defines a "thing" that can be rated, with a specific scope.
type Attribute struct {
ID string `json:"id"`
Name string `json:"name"`
Scope ScopeType `json:"scope"`
OwnerID string `json:"ownerID,omitempty"`
Description string `json:"description,omitempty"`
Attachment string `json:"attachment,omitempty"`
}
// Rating is the central record linking a user, place, and attribute.
type Rating struct {
ID string `json:"id"`
PlaceID string `json:"placeId"`
AttributeID string `json:"attributeId"`
UserID string `json:"userId"`
Score int `json:"score"` // 1-10
Comment string `json:"comment,omitempty"`
CreatedAt string `json:"createdAt"`
}
+11
View File
@@ -0,0 +1,11 @@
package store
import "git.pengzhan.dev/noteplace-server/internal/models"
func (s *Store) CreateAttribute(attr models.Attribute) error {
s.Mu.Lock()
defer s.Mu.Unlock()
s.Data.Attributes[attr.ID] = attr
return s.save()
}
+49
View File
@@ -0,0 +1,49 @@
package store
import "git.pengzhan.dev/noteplace-server/internal/models"
// CreatePlace adds a new place to the store and saves it.
func (s *Store) CreatePlace(place models.Place) error {
s.Mu.Lock()
defer s.Mu.Unlock()
s.Data.Places[place.ID] = place
return s.save() // Use an unexported save for internal consistency
}
// GetPlaceByID finds a place by its ID.
func (s *Store) GetPlaceByID(id string) (models.Place, bool) {
s.Mu.RLock()
defer s.Mu.RUnlock()
p, ok := s.Data.Places[id]
return p, ok
}
// GetRatingsByPlaceID finds all ratings for a given place ID.
func (s *Store) GetRatingsByPlaceID(placeID string) []models.Rating {
s.Mu.RLock()
defer s.Mu.RUnlock()
var results []models.Rating
for _, r := range s.Data.Ratings {
if r.PlaceID == placeID {
results = append(results, r)
}
}
return results
}
// GetAttributesByPlace finds all relevant attributes for a given place.
func (s *Store) GetAttributesByPlace(place models.Place) []models.Attribute {
s.Mu.RLock()
defer s.Mu.RUnlock()
var results []models.Attribute
for _, val := range s.Data.Attributes {
if val.Scope == models.CATEGORY && place.Category == val.OwnerID {
results = append(results, val)
}
if val.Scope == models.PLACE && place.ID == val.OwnerID {
results = append(results, val)
}
}
return results
}
+66
View File
@@ -0,0 +1,66 @@
package store
import "git.pengzhan.dev/noteplace-server/internal/models"
// CreateRating adds a new rating to the store.
func (s *Store) CreateRating(rating models.Rating) error {
s.Mu.Lock()
defer s.Mu.Unlock()
s.Data.Ratings[rating.ID] = rating
return s.save()
}
// GetRatingByID finds a rating by its ID.
func (s *Store) GetRatingByID(id string) (models.Rating, bool) {
s.Mu.RLock()
defer s.Mu.RUnlock()
r, ok := s.Data.Ratings[id]
return r, ok
}
// UpdateRating finds a rating by ID and updates its score and comment.
func (s *Store) UpdateRating(id string, score int, comment string) (models.Rating, bool) {
s.Mu.Lock()
defer s.Mu.Unlock()
or, ok := s.Data.Ratings[id]
if !ok {
return models.Rating{}, false
}
s.Data.Ratings[id] = models.Rating{
ID: id,
PlaceID: or.PlaceID,
AttributeID: or.AttributeID,
UserID: or.UserID,
Score: score,
Comment: comment,
CreatedAt: or.CreatedAt,
}
if err := s.save(); err != nil {
return models.Rating{}, false
}
return s.Data.Ratings[id], true
}
// DeleteRating removes a rating from the store by its ID.
func (s *Store) DeleteRating(id string) bool {
s.Mu.Lock()
defer s.Mu.Unlock()
delete(s.Data.Ratings, id)
if err := s.save(); err != nil {
return false
}
return true
}
// GetRatingsByUserID finds all ratings for a given user ID.
func (s *Store) GetRatingsByUserID(userID string) []models.Rating {
s.Mu.RLock()
defer s.Mu.RUnlock()
var results []models.Rating
for _, r := range s.Data.Ratings {
if r.UserID == userID {
results = append(results, r)
}
}
return results
}
+26
View File
@@ -0,0 +1,26 @@
package store
import "git.pengzhan.dev/noteplace-server/internal/models"
func (s *Store) CreateSession(session models.Session) error {
s.Mu.Lock()
defer s.Mu.Unlock()
s.Data.Sessions[session.ID] = session
return s.save()
}
// GetSessionByUserID finds a session by its ID.
func (s *Store) GetSessionBySessionID(sessionID string) (models.Session, bool) {
s.Mu.RLock()
defer s.Mu.RUnlock()
se, ok := s.Data.Sessions[sessionID]
return se, ok
}
// DeleteSession removes a session from the store.
func (s *Store) DeleteSession(sessionID string) error {
s.Mu.Lock()
defer s.Mu.Unlock()
delete(s.Data.Sessions, sessionID)
return s.save()
}
+82
View File
@@ -0,0 +1,82 @@
package store
import (
"encoding/json"
"os"
"sync"
"git.pengzhan.dev/noteplace-server/internal/models"
)
// DB represents the structure of the entire JSON database.
type DB struct {
Users map[string]models.User `json:"users"`
Sessions map[string]models.Session `json:"sessions"`
Places map[string]models.Place `json:"places"`
Addresses map[string]models.Address `json:"addresses"`
Categories map[string]models.Category `json:"categories"`
Attributes map[string]models.Attribute `json:"attributes"`
Ratings map[string]models.Rating `json:"ratings"`
}
// Store manages the in-memory data and provides safe access.
type Store struct {
Mu sync.RWMutex
path string
Data *DB
}
// New creates and initializes a new Store.
func New(path string) (*Store, error) {
s := &Store{
path: path,
Data: &DB{},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
// load reads the database file from disk into the Store.
func (s *Store) load() error {
s.Mu.Lock()
defer s.Mu.Unlock()
file, err := os.ReadFile(s.path)
if err != nil {
// If the file doesn't exist, we can assume an empty database.
if os.IsNotExist(err) {
s.Data = &DB{
Users: map[string]models.User{},
Sessions: map[string]models.Session{},
Places: map[string]models.Place{},
Addresses: map[string]models.Address{},
Categories: map[string]models.Category{},
Attributes: map[string]models.Attribute{},
Ratings: map[string]models.Rating{},
}
return nil
}
return err
}
return json.Unmarshal(file, s.Data)
}
// Save writes the current in-memory data back to the JSON file.
func (s *Store) Save() error {
s.Mu.RLock()
defer s.Mu.RUnlock()
data, err := json.MarshalIndent(s.Data, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}
// CreateSession adds a new session to the store.
+572
View File
@@ -0,0 +1,572 @@
package store
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
"git.pengzhan.dev/noteplace-server/internal/models"
)
// setupTestStore creates a temporary JSON file and a new Store instance for testing.
// It returns the Store, the path to the temporary file, and a cleanup function.
func setupTestStore(t *testing.T, initialData *DB) (*Store, string, func()) {
tempDir, err := os.MkdirTemp("", "noteplace_test_")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
tempFilePath := filepath.Join(tempDir, "test_db.json")
if initialData != nil {
data, err := json.MarshalIndent(initialData, "", " ")
if err != nil {
t.Fatalf("Failed to marshal initial data: %v", err)
}
if err := os.WriteFile(tempFilePath, data, 0644); err != nil {
t.Fatalf("Failed to write initial data to temp file: %v", err)
}
}
store, err := New(tempFilePath)
if err != nil {
t.Fatalf("Failed to create new store: %v", err)
}
cleanup := func() {
_ = os.RemoveAll(tempDir)
}
return store, tempFilePath, cleanup
}
// func TestNew(t *testing.T) {
// t.Run("creates new store with empty file", func(t *testing.T) {
// _, tempFilePath, cleanup := setupTestStore(t, nil)
// defer cleanup()
// store, err := New(tempFilePath)
// if err != nil {
// t.Fatalf("New() error = %v, wantErr %v", err, nil)
// }
// if store == nil {
// t.Fatal("New() returned nil store")
// }
// if store.Data == nil {
// t.Fatal("New() store.Data is nil")
// }
// if len(store.Data.Users) != 0 || len(store.Data.Places) != 0 || len(store.Data.Attributes) != 0 || len(store.Data.Ratings) != 0 {
// t.Errorf("New() store.Data not empty for new file, got %+v", store.Data)
// }
// })
// t.Run("loads existing data from file", func(t *testing.T) {
// initialUser := models.User{ID: "user1", GoogleID: "google1", DisplayName: "Test User"}
// initialDB := &DB{Users: []models.User{initialUser}}
// store, _, cleanup := setupTestStore(t, initialDB)
// defer cleanup()
// if len(store.Data.Users) != 1 || store.Data.Users[0].ID != initialUser.ID {
// t.Errorf("New() did not load initial user, got %+v", store.Data.Users)
// }
// })
// t.Run("returns error for invalid file path", func(t *testing.T) {
// // Attempt to create a store with a path that's likely to cause permission issues
// store, err := New("/nonexistent/path/to/db.json")
// if err == nil {
// t.Fatal("New() did not return error for invalid path")
// }
// if store != nil {
// t.Fatal("New() returned non-nil store for invalid path")
// }
// })
// }
// func TestLoad(t *testing.T) {
// t.Run("loads data from valid JSON file", func(t *testing.T) {
// initialUser := models.User{ID: "user1", GoogleID: "google1", DisplayName: "Test User"}
// initialDB := &DB{Users: []models.User{initialUser}}
// store, tempFilePath, cleanup := setupTestStore(t, initialDB)
// defer cleanup()
// // Clear store data to ensure load actually reloads
// store.Data = &DB{}
// if err := store.load(); err != nil {
// t.Fatalf("load() error = %v", err)
// }
// if len(store.Data.Users) != 1 || store.Data.Users[0].ID != initialUser.ID {
// t.Errorf("load() did not load expected user, got %+v", store.Data.Users)
// }
// })
// t.Run("initializes empty DB if file does not exist", func(t *testing.T) {
// tempDir, err := ioutil.TempDir("", "noteplace_test_")
// if err != nil {
// t.Fatalf("Failed to create temp dir: %v", err)
// }
// tempFilePath := filepath.Join(tempDir, "non_existent_db.json")
// defer os.RemoveAll(tempDir)
// store := &Store{path: tempFilePath, Data: &DB{}}
// if err := store.load(); err != nil {
// t.Fatalf("load() error for non-existent file = %v", err)
// }
// if len(store.Data.Users) != 0 || len(store.Data.Places) != 0 || len(store.Data.Attributes) != 0 || len(store.Data.Ratings) != 0 {
// t.Errorf("load() did not initialize empty DB for non-existent file, got %+v", store.Data)
// }
// })
// t.Run("returns error for invalid JSON file", func(t *testing.T) {
// tempDir, err := ioutil.TempDir("", "noteplace_test_")
// if err != nil {
// t.Fatalf("Failed to create temp dir: %v", err)
// }
// tempFilePath := filepath.Join(tempDir, "invalid_db.json")
// defer os.RemoveAll(tempDir)
// if err := ioutil.WriteFile(tempFilePath, []byte("{invalid json"), 0644); err != nil {
// t.Fatalf("Failed to write invalid JSON to temp file: %v", err)
// }
// store := &Store{path: tempFilePath, Data: &DB{}}
// if err := store.load(); err == nil {
// t.Fatal("load() did not return error for invalid JSON")
// }
// })
// }
// func TestSave(t *testing.T) {
// t.Run("saves current data to file", func(t *testing.T) {
// store, tempFilePath, cleanup := setupTestStore(t, nil)
// defer cleanup()
// testUser := models.User{ID: "user1", GoogleID: "google1", Name: "Test User"}
// store.Data.Users = append(store.Data.Users, testUser)
// if err := store.Save(); err != nil {
// t.Fatalf("Save() error = %v", err)
// }
// // Verify content of the file
// fileContent, err := ioutil.ReadFile(tempFilePath)
// if err != nil {
// t.Fatalf("Failed to read saved file: %v", err)
// }
// var loadedDB DB
// if err := json.Unmarshal(fileContent, &loadedDB); err != nil {
// t.Fatalf("Failed to unmarshal saved file content: %v", err)
// }
// if len(loadedDB.Users) != 1 || loadedDB.Users[0].ID != testUser.ID {
// t.Errorf("Save() did not save expected user, got %+v", loadedDB.Users)
// }
// })
// t.Run("returns error if file cannot be written", func(t *testing.T) {
// // Create a read-only directory to simulate unwritable path
// tempDir, err := ioutil.TempDir("", "noteplace_test_")
// if err != nil {
// t.Fatalf("Failed to create temp dir: %v", err)
// }
// defer os.RemoveAll(tempDir)
// readOnlyFilePath := filepath.Join(tempDir, "read_only_db.json")
// // Make the directory read-only
// if err := os.Chmod(tempDir, 0444); err != nil {
// t.Fatalf("Failed to chmod temp dir: %v", err)
// }
// store := &Store{path: readOnlyFilePath, Data: &DB{}}
// store.Data.Users = append(store.Data.Users, models.User{ID: "user1"})
// if err := store.Save(); err == nil {
// t.Fatal("Save() did not return error for unwritable path")
// }
// // Change permissions back to allow cleanup
// os.Chmod(tempDir, 0755)
// })
// }
// func TestCreatePlace(t *testing.T) {
// store, _, cleanup := setupTestStore(t, nil)
// defer cleanup()
// place := models.Place{ID: "place1", Name: "Test Place", Category: "Restaurant"}
// err := store.CreatePlace(place)
// if err != nil {
// t.Fatalf("CreatePlace() error = %v", err)
// }
// if len(store.Data.Places) != 1 || store.Data.Places[0].ID != place.ID {
// t.Errorf("CreatePlace() did not add place, got %+v", store.Data.Places)
// }
// // Verify it's saved to disk
// reloadedStore, err := New(store.path)
// if err != nil {
// t.Fatalf("Failed to reload store: %v", err)
// }
// if len(reloadedStore.Data.Places) != 1 || reloadedStore.Data.Places[0].ID != place.ID {
// t.Errorf("CreatePlace() did not save place to disk, got %+v", reloadedStore.Data.Places)
// }
// }
// func TestGetPlaceByID(t *testing.T) {
// initialPlace := models.Place{ID: "place1", Name: "Test Place"}
// initialDB := &DB{Places: []models.Place{initialPlace}}
// store, _, cleanup := setupTestStore(t, initialDB)
// defer cleanup()
// t.Run("finds existing place", func(t *testing.T) {
// foundPlace, found := store.GetPlaceByID("place1")
// if !found {
// t.Fatal("GetPlaceByID() did not find existing place")
// }
// if foundPlace.ID != initialPlace.ID {
// t.Errorf("GetPlaceByID() got place %+v, want %+v", foundPlace, initialPlace)
// }
// })
// t.Run("does not find non-existent place", func(t *testing.T) {
// _, found := store.GetPlaceByID("nonexistent")
// if found {
// t.Fatal("GetPlaceByID() found non-existent place")
// }
// })
// }
// func TestGetRatingsByPlaceID(t *testing.T) {
// rating1 := models.Rating{ID: "r1", PlaceID: "place1", UserID: "u1", Score: 5}
// rating2 := models.Rating{ID: "r2", PlaceID: "place1", UserID: "u2", Score: 4}
// rating3 := models.Rating{ID: "r3", PlaceID: "place2", UserID: "u1", Score: 3}
// initialDB := &DB{Ratings: []models.Rating{rating1, rating2, rating3}}
// store, _, cleanup := setupTestStore(t, initialDB)
// defer cleanup()
// t.Run("finds multiple ratings for a place", func(t *testing.T) {
// ratings := store.GetRatingsByPlaceID("place1")
// if len(ratings) != 2 {
// t.Fatalf("GetRatingsByPlaceID() got %d ratings, want 2", len(ratings))
// }
// expected := []models.Rating{rating1, rating2}
// if !reflect.DeepEqual(ratings, expected) {
// t.Errorf("GetRatingsByPlaceID() got %+v, want %+v", ratings, expected)
// }
// })
// t.Run("returns empty slice for place with no ratings", func(t *testing.T) {
// ratings := store.GetRatingsByPlaceID("place3")
// if len(ratings) != 0 {
// t.Errorf("GetRatingsByPlaceID() got %d ratings, want 0 for place3", len(ratings))
// }
// })
// }
// func TestGetAttributesByPlace(t *testing.T) {
// place1 := models.Place{ID: "p1", Category: "Restaurant"}
// place2 := models.Place{ID: "p2", Category: "Cafe"}
// attrCommonRest := models.Attribute{ID: "a1", Scope: "COMMON", PlaceCategory: "Restaurant", Name: "Outdoor Seating"}
// attrSpecificP1 := models.Attribute{ID: "a2", Scope: "PLACE_SPECIFIC", PlaceID: "p1", Name: "Live Music"}
// attrCommonCafe := models.Attribute{ID: "a3", Scope: "COMMON", PlaceCategory: "Cafe", Name: "Wifi"}
// attrSpecificP2 := models.Attribute{ID: "a4", Scope: "PLACE_SPECIFIC", PlaceID: "p2", Name: "Pet Friendly"}
// initialDB := &DB{
// Places: []models.Place{place1, place2},
// Attributes: []models.Attribute{attrCommonRest, attrSpecificP1, attrCommonCafe, attrSpecificP2},
// }
// store, _, cleanup := setupTestStore(t, initialDB)
// defer cleanup()
// t.Run("finds common and place-specific attributes for place1", func(t *testing.T) {
// attrs := store.GetAttributesByPlace(place1)
// if len(attrs) != 2 {
// t.Fatalf("GetAttributesByPlace() got %d attributes for place1, want 2", len(attrs))
// }
// expected := []models.Attribute{attrCommonRest, attrSpecificP1}
// if !reflect.DeepEqual(attrs, expected) {
// t.Errorf("GetAttributesByPlace() got %+v, want %+v", attrs, expected)
// }
// })
// t.Run("finds common and place-specific attributes for place2", func(t *testing.T) {
// attrs := store.GetAttributesByPlace(place2)
// if len(attrs) != 2 {
// t.Fatalf("GetAttributesByPlace() got %d attributes for place2, want 2", len(attrs))
// }
// expected := []models.Attribute{attrCommonCafe, attrSpecificP2}
// if !reflect.DeepEqual(attrs, expected) {
// t.Errorf("GetAttributesByPlace() got %+v, want %+v", attrs, expected)
// }
// })
// t.Run("returns empty slice for place with no relevant attributes", func(t *testing.T) {
// place3 := models.Place{ID: "p3", Category: "Bar"}
// attrs := store.GetAttributesByPlace(place3)
// if len(attrs) != 0 {
// t.Errorf("GetAttributesByPlace() got %d attributes, want 0 for place3", len(attrs))
// }
// })
// }
// func TestCreateAttribute(t *testing.T) {
// store, _, cleanup := setupTestStore(t, nil)
// defer cleanup()
// attr := models.Attribute{ID: "attr1", Name: "Parking", Scope: "COMMON", PlaceCategory: "Restaurant"}
// err := store.CreateAttribute(attr)
// if err != nil {
// t.Fatalf("CreateAttribute() error = %v", err)
// }
// if len(store.Data.Attributes) != 1 || store.Data.Attributes[0].ID != attr.ID {
// t.Errorf("CreateAttribute() did not add attribute, got %+v", store.Data.Attributes)
// }
// // Verify it's saved to disk
// reloadedStore, err := New(store.path)
// if err != nil {
// t.Fatalf("Failed to reload store: %v", err)
// }
// if len(reloadedStore.Data.Attributes) != 1 || reloadedStore.Data.Attributes[0].ID != attr.ID {
// t.Errorf("CreateAttribute() did not save attribute to disk, got %+v", reloadedStore.Data.Attributes)
// }
// }
// func TestCreateRating(t *testing.T) {
// store, _, cleanup := setupTestStore(t, nil)
// defer cleanup()
// rating := models.Rating{ID: "r1", PlaceID: "p1", UserID: "u1", Score: 5, Comment: "Great!"}
// err := store.CreateRating(rating)
// if err != nil {
// t.Fatalf("CreateRating() error = %v", err)
// }
// if len(store.Data.Ratings) != 1 || store.Data.Ratings[0].ID != rating.ID {
// t.Errorf("CreateRating() did not add rating, got %+v", store.Data.Ratings)
// }
// // Verify it's saved to disk
// reloadedStore, err := New(store.path)
// if err != nil {
// t.Fatalf("Failed to reload store: %v", err)
// }
// if len(reloadedStore.Data.Ratings) != 1 || reloadedStore.Data.Ratings[0].ID != rating.ID {
// t.Errorf("CreateRating() did not save rating to disk, got %+v", reloadedStore.Data.Ratings)
// }
// }
// func TestGetRatingByID(t *testing.T) {
// initialRating := models.Rating{ID: "r1", PlaceID: "p1", UserID: "u1", Score: 5}
// initialDB := &DB{Ratings: []models.Rating{initialRating}}
// store, _, cleanup := setupTestStore(t, initialDB)
// defer cleanup()
// t.Run("finds existing rating", func(t *testing.T) {
// foundRating, found := store.GetRatingByID("r1")
// if !found {
// t.Fatal("GetRatingByID() did not find existing rating")
// }
// if foundRating.ID != initialRating.ID {
// t.Errorf("GetRatingByID() got rating %+v, want %+v", foundRating, initialRating)
// }
// })
// t.Run("does not find non-existent rating", func(t *testing.T) {
// _, found := store.GetRatingByID("nonexistent")
// if found {
// t.Fatal("GetRatingByID() found non-existent rating")
// }
// })
// }
func TestUpdateRating(t *testing.T) {
initialRating := models.Rating{ID: "r1", PlaceID: "p1", UserID: "u1", Score: 5, Comment: "Good"}
initialDB := &DB{Ratings: map[string]models.Rating{
initialRating.ID: initialRating}}
store, _, cleanup := setupTestStore(t, initialDB)
defer cleanup()
t.Run("updates existing rating", func(t *testing.T) {
updatedScore := 4
updatedComment := "It was okay"
updatedRating, updated := store.UpdateRating("r1", updatedScore, updatedComment)
if !updated {
t.Fatal("UpdateRating() did not update existing rating")
}
if updatedRating.Score != updatedScore || updatedRating.Comment != updatedComment {
t.Errorf("UpdateRating() got score %d, comment %s; want %d, %s", updatedRating.Score, updatedRating.Comment, updatedScore, updatedComment)
}
// Verify it's saved to disk
reloadedStore, err := New(store.path)
if err != nil {
t.Fatalf("Failed to reload store: %v", err)
}
foundRating, found := reloadedStore.GetRatingByID("r1")
if !found || foundRating.Score != updatedScore || foundRating.Comment != updatedComment {
t.Errorf("UpdateRating() did not save updated rating to disk, got %+v", foundRating)
}
})
t.Run("does not update non-existent rating", func(t *testing.T) {
_, updated := store.UpdateRating("nonexistent", 1, "bad")
if updated {
t.Fatal("UpdateRating() updated non-existent rating")
}
})
}
func TestDeleteRating(t *testing.T) {
rating1 := models.Rating{ID: "r1", PlaceID: "p1", UserID: "u1", Score: 5}
rating2 := models.Rating{ID: "r2", PlaceID: "p2", UserID: "u2", Score: 4}
initialDB := &DB{Ratings: map[string]models.Rating{
rating1.ID: rating1,
rating2.ID: rating2,
}}
store, _, cleanup := setupTestStore(t, initialDB)
defer cleanup()
t.Run("deletes existing rating", func(t *testing.T) {
deleted := store.DeleteRating("r1")
if !deleted {
t.Fatal("DeleteRating() did not delete existing rating")
}
if len(store.Data.Ratings) != 1 {
t.Errorf("DeleteRating() did not remove rating, got %+v", store.Data.Ratings)
}
// Verify it's saved to disk
reloadedStore, err := New(store.path)
if err != nil {
t.Fatalf("Failed to reload store: %v", err)
}
_, found := reloadedStore.GetRatingByID("r1")
if found {
t.Error("DeleteRating() did not remove rating from disk")
}
if len(reloadedStore.Data.Ratings) != 1 {
t.Errorf("DeleteRating() disk state incorrect, got %d ratings, want 1", len(reloadedStore.Data.Ratings))
}
})
t.Run("does not delete non-existent rating", func(t *testing.T) {
// Re-initialize store for this sub-test to have original data
store, _, cleanup := setupTestStore(t, initialDB)
defer cleanup()
store.DeleteRating("nonexistent")
if len(store.Data.Ratings) != 2 {
t.Errorf("DeleteRating() modified ratings for non-existent ID, got %d", len(store.Data.Ratings))
}
})
}
func TestGetRatingsByUserID(t *testing.T) {
rating1 := models.Rating{ID: "r1", PlaceID: "p1", UserID: "u1", Score: 5}
rating2 := models.Rating{ID: "r2", PlaceID: "p2", UserID: "u1", Score: 4}
rating3 := models.Rating{ID: "r3", PlaceID: "p1", UserID: "u2", Score: 3}
initialDB := &DB{Ratings: map[string]models.Rating{
rating1.ID: rating1,
rating2.ID: rating2,
rating3.ID: rating3,
}}
store, _, cleanup := setupTestStore(t, initialDB)
defer cleanup()
t.Run("finds multiple ratings for a user", func(t *testing.T) {
ratings := store.GetRatingsByUserID("u1")
if len(ratings) != 2 {
t.Fatalf("GetRatingsByUserID() got %d ratings, want 2", len(ratings))
}
expected := []models.Rating{rating1, rating2}
if !reflect.DeepEqual(ratings, expected) {
t.Errorf("GetRatingsByUserID() got %+v, want %+v", ratings, expected)
}
})
t.Run("returns empty slice for user with no ratings", func(t *testing.T) {
ratings := store.GetRatingsByUserID("u3")
if len(ratings) != 0 {
t.Errorf("GetRatingsByUserID() got %d ratings, want 0 for u3", len(ratings))
}
})
}
func TestGetUserByGoogleID(t *testing.T) {
initialUser := models.User{ID: "u1", GoogleID: "google1", DisplayName: "Test User 1"}
initialDB := &DB{Users: map[string]models.User{initialUser.ID: initialUser}}
store, _, cleanup := setupTestStore(t, initialDB)
defer cleanup()
t.Run("finds existing user by Google ID", func(t *testing.T) {
foundUser, found := store.GetUserByGoogleID("google1")
if !found {
t.Fatal("GetUserByGoogleID() did not find existing user")
}
if foundUser.ID != initialUser.ID {
t.Errorf("GetUserByGoogleID() got user %+v, want %+v", foundUser, initialUser)
}
})
t.Run("does not find non-existent user by Google ID", func(t *testing.T) {
_, found := store.GetUserByGoogleID("nonexistent_google_id")
if found {
t.Fatal("GetUserByGoogleID() found non-existent user")
}
})
}
func TestGetUserByID(t *testing.T) {
initialUser := models.User{ID: "u1", GoogleID: "google1", DisplayName: "Test User 1"}
initialDB := &DB{Users: map[string]models.User{initialUser.ID: initialUser}}
store, _, cleanup := setupTestStore(t, initialDB)
defer cleanup()
t.Run("finds existing user by ID", func(t *testing.T) {
foundUser, found := store.GetUserByID("u1")
if !found {
t.Fatal("GetUserByID() did not find existing user")
}
if foundUser.ID != initialUser.ID {
t.Errorf("GetUserByID() got user %+v, want %+v", foundUser, initialUser)
}
})
t.Run("does not find non-existent user by ID", func(t *testing.T) {
_, found := store.GetUserByID("nonexistent_id")
if found {
t.Fatal("GetUserByID() found non-existent user")
}
})
}
func TestCreateUser(t *testing.T) {
store, _, cleanup := setupTestStore(t, nil)
defer cleanup()
user := models.User{ID: "u1", GoogleID: "google1", DisplayName: "Test User"}
err := store.CreateUser(user)
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
if _, ok := store.Data.Users[user.ID]; len(store.Data.Users) != 1 || !ok {
t.Errorf("CreateUser() did not add user, got %+v", store.Data.Users)
}
// Verify it's saved to disk
reloadedStore, err := New(store.path)
if err != nil {
t.Fatalf("Failed to reload store: %v", err)
}
if _, ok := reloadedStore.Data.Users[user.ID]; len(reloadedStore.Data.Users) != 1 || !ok {
t.Errorf("CreateUser() did not save user to disk, got %+v", reloadedStore.Data.Users)
}
}
+45
View File
@@ -0,0 +1,45 @@
package store
import (
"encoding/json"
"os"
"git.pengzhan.dev/noteplace-server/internal/models"
)
// GetUserByGoogleID finds a user by their Google ID.
func (s *Store) GetUserByGoogleID(googleID string) (models.User, bool) {
s.Mu.RLock()
defer s.Mu.RUnlock()
for _, u := range s.Data.Users {
if u.GoogleID == googleID {
return u, true
}
}
return models.User{}, false
}
// GetUserByID finds a user by their internal ID.
func (s *Store) GetUserByID(id string) (models.User, bool) {
s.Mu.RLock()
defer s.Mu.RUnlock()
u, ok := s.Data.Users[id]
return u, ok
}
// CreateUser adds a new user to the store.
func (s *Store) CreateUser(user models.User) error {
s.Mu.Lock()
defer s.Mu.Unlock()
s.Data.Users[user.ID] = user
return s.save()
}
// save is an unexported helper to save the database state.
func (s *Store) save() error {
data, err := json.MarshalIndent(s.Data, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}