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:
@@ -1,7 +1,9 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
@@ -126,3 +128,60 @@ func CreateGuestUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(CreateGuestUserResponse{ID: userID, Role: "guest"})
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/testutils/fixtures"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestGuestUser_Create_InvalidPhone verifies that an invalid phone number returns 400 Bad Request.
|
// 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())
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user