From 6de0371e1d28caa8b2e2e1ebb46a3bc0bae28e50 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 11 Jun 2026 22:08:22 +0100 Subject: [PATCH] feat(user): add email check endpoint for registered user detection Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/user/guest.go | 59 ++++++++++ backend/handlers/user/guest_test.go | 163 ++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) diff --git a/backend/handlers/user/guest.go b/backend/handlers/user/guest.go index 5ad9fe4..e2c01fe 100644 --- a/backend/handlers/user/guest.go +++ b/backend/handlers/user/guest.go @@ -1,7 +1,9 @@ package user import ( + "database/sql" "encoding/json" + "errors" "log" "net/http" "net/mail" @@ -126,3 +128,60 @@ func CreateGuestUserHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(CreateGuestUserResponse{ID: userID, Role: "guest"}) } + +// GET /api/check-email?email=...&firstName=...&lastName=...&phone=... +// Returns { suggestion: "login" | "check" | null } based on whether the email +// belongs to a registered user and how closely the provided details match. +// Relies on nginx restricting access to frontend-only traffic. +func CheckEmailHandler(w http.ResponseWriter, r *http.Request) { + email := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("email"))) + if email == "" { + http.Error(w, "email query parameter required", http.StatusBadRequest) + return + } + + if _, err := mail.ParseAddress(email); err != nil { + http.Error(w, "invalid email format", http.StatusBadRequest) + return + } + + firstName := strings.TrimSpace(r.URL.Query().Get("firstName")) + lastName := strings.TrimSpace(r.URL.Query().Get("lastName")) + phone := strings.TrimSpace(r.URL.Query().Get("phone")) + + var dbFirstName, dbLastName, dbPhone *string + err := db.DB.QueryRow(r.Context(), ` + SELECT n_first_name, n_last_name, phone + FROM users + WHERE email = $1 AND account_role != 'guest' + `, email).Scan(&dbFirstName, &dbLastName, &dbPhone) + + var suggestion *string + if err == nil { + matchesNames := firstName != "" && lastName != "" && + dbFirstName != nil && dbLastName != nil && + strings.EqualFold(firstName, *dbFirstName) && + strings.EqualFold(lastName, *dbLastName) + + matchesPhone := phone != "" && + dbPhone != nil && + phone == *dbPhone + + if matchesNames && matchesPhone { + s := "login" + suggestion = &s + } else { + s := "check" + suggestion = &s + } + } else if !errors.Is(err, sql.ErrNoRows) { + log.Printf("Failed to check email: %v", err) + http.Error(w, "database error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "suggestion": suggestion, + }) +} diff --git a/backend/handlers/user/guest_test.go b/backend/handlers/user/guest_test.go index b334167..4dbe505 100644 --- a/backend/handlers/user/guest_test.go +++ b/backend/handlers/user/guest_test.go @@ -16,11 +16,15 @@ package user import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" + + "crussell/db" + "crussell/testutils/fixtures" ) // TestGuestUser_Create_InvalidPhone verifies that an invalid phone number returns 400 Bad Request. @@ -118,3 +122,162 @@ func TestGuestUser_Create_InvalidEmail(t *testing.T) { t.Logf("response body: %s", rr.Body.String()) } } + +// TestCheckEmail_NotRegistered verifies that querying a non-existent email returns suggestion null. +func TestCheckEmail_NotRegistered(t *testing.T) { + resetTestData(t) + + req := httptest.NewRequest(http.MethodGet, "/api/check-email?email=nobody@example.com&firstName=Jane&lastName=Doe&phone=%2B447123456789", nil) + rr := httptest.NewRecorder() + CheckEmailHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + t.Logf("response body: %s", rr.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if resp["suggestion"] != nil { + t.Errorf("expected suggestion null, got %v", resp["suggestion"]) + } +} + +// TestCheckEmail_Registered_MatchingDetails verifies that querying an existing registered user's email +// with matching first name, last name, and phone returns suggestion "login". +func TestCheckEmail_Registered_MatchingDetails(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUserWithEmail(db.DB, "jane@example.com", "verified_email") + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + _, err = db.DB.Exec(context.Background(), ` + UPDATE users SET n_first_name = 'Jane', n_last_name = 'Doe' WHERE id = $1 + `, userID) + if err != nil { + t.Fatalf("failed to update user name: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, `/api/check-email?email=jane@example.com&firstName=Jane&lastName=Doe&phone=%2B447123456789`, nil) + rr := httptest.NewRecorder() + CheckEmailHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + t.Logf("response body: %s", rr.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + suggestion, ok := resp["suggestion"].(string) + if !ok || suggestion != "login" { + t.Errorf("expected suggestion 'login', got %v", resp["suggestion"]) + } +} + +// TestCheckEmail_Registered_PartialMatch verifies that when the email exists but details don't fully match, +// the handler returns suggestion "check". +func TestCheckEmail_Registered_PartialMatch(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUserWithEmail(db.DB, "jane@example.com", "verified_email") + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + _, err = db.DB.Exec(context.Background(), ` + UPDATE users SET n_first_name = 'Jane', n_last_name = 'Doe' WHERE id = $1 + `, userID) + if err != nil { + t.Fatalf("failed to update user name: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, `/api/check-email?email=jane@example.com&firstName=Wrong&lastName=Doe&phone=%2B447123456789`, nil) + rr := httptest.NewRecorder() + CheckEmailHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + t.Logf("response body: %s", rr.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + suggestion, ok := resp["suggestion"].(string) + if !ok || suggestion != "check" { + t.Errorf("expected suggestion 'check', got %v", resp["suggestion"]) + } +} + +// TestCheckEmail_GuestUser verifies that a guest user's email is treated as not found +// (suggestion null) because the query excludes account_role = 'guest'. +func TestCheckEmail_GuestUser(t *testing.T) { + resetTestData(t) + + _, err := fixtures.CreateTestGuestUser(db.DB) + if err != nil { + t.Fatalf("failed to create guest user: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, `/api/check-email?email=guest@test.com&firstName=Guest&lastName=User&phone=%2B447123456789`, nil) + rr := httptest.NewRecorder() + CheckEmailHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + t.Logf("response body: %s", rr.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if resp["suggestion"] != nil { + t.Errorf("expected suggestion null for guest user, got %v", resp["suggestion"]) + } +} + +// TestCheckEmail_InvalidEmail verifies that an invalid email format returns 400 Bad Request. +func TestCheckEmail_InvalidEmail(t *testing.T) { + resetTestData(t) + + req := httptest.NewRequest(http.MethodGet, "/api/check-email?email=not-an-email", nil) + rr := httptest.NewRecorder() + CheckEmailHandler(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rr.Code) + t.Logf("response body: %s", rr.Body.String()) + } +} + +// TestCheckEmail_MissingEmail verifies that omitting the email query parameter returns 400 Bad Request +// with the appropriate error message. +func TestCheckEmail_MissingEmail(t *testing.T) { + resetTestData(t) + + req := httptest.NewRequest(http.MethodGet, "/api/check-email", nil) + rr := httptest.NewRecorder() + CheckEmailHandler(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rr.Code) + t.Logf("response body: %s", rr.Body.String()) + } + + if !strings.Contains(rr.Body.String(), "email query parameter required") { + t.Errorf("expected 'email query parameter required' in body, got %s", rr.Body.String()) + } +}