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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-11 22:08:22 +01:00
co-authored by Sisyphus
parent b750b3c203
commit 6de0371e1d
2 changed files with 222 additions and 0 deletions
+59
View File
@@ -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,
})
}
+163
View File
@@ -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())
}
}