Add leads handlers, WhatsApp integration, and test files

This commit is contained in:
2026-05-02 17:40:38 +00:00
parent 8833e38b80
commit 57e6e5a6fd
24 changed files with 2305 additions and 115 deletions

View File

@@ -200,12 +200,22 @@ func GetAccountID(r *http.Request) (int64, error) {
func requireAuth(w http.ResponseWriter, r *http.Request) (int64, bool) {
accountID, err := getSession(r)
if err != nil {
http.Redirect(w, r, "/auth/login", http.StatusFound)
if isAPIRequest(r) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"error":"unauthorized"}`))
} else {
http.Redirect(w, r, "/auth/login", http.StatusFound)
}
return 0, false
}
return accountID, true
}
func isAPIRequest(r *http.Request) bool {
accept := r.Header.Get("Accept")
return accept == "application/json" || r.URL.Path == "/leads/qr"
}
func SetupAuthHandlers(db *sql.DB) {
DB = db
chi.RegisterMethod("GET")

View File

@@ -54,15 +54,21 @@ func ListClients(w http.ResponseWriter, r *http.Request) {
</div>
<table>
<thead>
<tr><th>Name</th><th>Phone</th><th>Email</th><th>Actions</th></tr>
<tr><th>Name</th><th>Phone</th><th>WhatsApp</th><th>Actions</th></tr>
</thead>
<tbody id="clientList">
`))
for _, c := range clients {
var whatsappCell string
if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" {
whatsappCell = c.WhatsAppNumber
} else {
whatsappCell = `<a href="/leads/connect?client_id=` + strconv.FormatInt(c.ClientID, 10) + `">Connect</a>`
}
w.Write([]byte(`<tr>
<td>` + c.Name + `</td>
<td>` + c.Phone + `</td>
<td>` + c.Email + `</td>
<td>` + whatsappCell + `</td>
<td>
<a href="/clients/` + strconv.FormatInt(c.ClientID, 10) + `">View</a>
<button type="button" onclick="document.getElementById('editForm` + strconv.FormatInt(c.ClientID, 10) + `').style.display='block'">Edit</button>

View File

@@ -0,0 +1,213 @@
package handlers
import (
"database/sql"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"go-crm/internal/db"
"go-crm/internal/whatsapp"
"golang.org/x/crypto/bcrypt"
)
func TestListClientsWhatsAppDisplay(t *testing.T) {
tmpfile, err := os.CreateTemp("", "test_*.db")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpfile.Name())
tmpfile.Close()
testDB, err := db.Init(tmpfile.Name())
if err != nil {
t.Fatalf("failed to init db: %v", err)
}
defer testDB.Close()
DB = testDB
WAConnector = whatsapp.NewFakeConnector()
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
_, err = testDB.Exec(
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
"test@example.com", "Test Account", string(hashedPassword), time.Now().Unix(),
)
if err != nil {
t.Fatalf("failed to create account: %v", err)
}
var accountID int64
err = testDB.QueryRow("SELECT account_id FROM accounts WHERE email = ?", "test@example.com").Scan(&accountID)
if err != nil {
t.Fatalf("failed to get account id: %v", err)
}
sessionID := "test-session"
expires := time.Now().Add(time.Hour).Unix()
_, err = testDB.Exec(
"INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)",
sessionID, accountID, expires,
)
if err != nil {
t.Fatalf("failed to create session: %v", err)
}
_, err = testDB.Exec(
"INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?)",
accountID, "Client Without WhatsApp", "+5521987654321", time.Now().Unix(),
)
if err != nil {
t.Fatalf("failed to create client without wa: %v", err)
}
_, err = testDB.Exec(
"INSERT INTO clients (account_id, name, phone, whatsapp_number, whatsapp_connected, created_at) VALUES (?, ?, ?, ?, ?, ?)",
accountID, "Client With WhatsApp", "+5521987654322", "+5521987654322", 1, time.Now().Unix(),
)
if err != nil {
t.Fatalf("failed to create client with wa: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/clients", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
w := httptest.NewRecorder()
ListClients(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "Client Without WhatsApp") {
t.Error("expected to find client without wa in response")
}
if !strings.Contains(body, "Connect") {
t.Error("expected Connect button for client without WhatsApp")
}
if !strings.Contains(body, "Client With WhatsApp") {
t.Error("expected to find client with wa in response")
}
if !strings.Contains(body, "+5521987654322") {
t.Error("expected WhatsApp number displayed for connected client")
}
}
func SetupTestDB(t *testing.T) (*sql.DB, func()) {
tmpfile, err := os.CreateTemp("", "test_*.db")
if err != nil {
t.Fatal(err)
}
tmpfile.Close()
testDB, err := db.Init(tmpfile.Name())
if err != nil {
os.Remove(tmpfile.Name())
t.Fatalf("failed to init db: %v", err)
}
cleanup := func() {
testDB.Close()
os.Remove(tmpfile.Name())
}
return testDB, cleanup
}
func TestLeadsConnectPage(t *testing.T) {
testDB, cleanup := SetupTestDB(t)
defer cleanup()
DB = testDB
WAConnector = whatsapp.NewFakeConnector()
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
testDB.Exec(
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
"test@example.com", "Test Account", string(hashedPassword), time.Now().Unix(),
)
var accountID int64
testDB.QueryRow("SELECT account_id FROM accounts WHERE email = ?", "test@example.com").Scan(&accountID)
sessionID := "test-session"
testDB.Exec(
"INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)",
sessionID, accountID, time.Now().Add(time.Hour).Unix(),
)
var clientID int64
testDB.QueryRow(
"INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?) RETURNING client_id",
accountID, "Test Client", "+5521987654321", time.Now().Unix(),
).Scan(&clientID)
req := httptest.NewRequest(http.MethodGet, "/leads/connect?client_id="+strconv.FormatInt(clientID, 10), nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
w := httptest.NewRecorder()
LeadsConnectPage(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "Scan to Connect") && !strings.Contains(body, "WhatsApp") {
t.Error("expected QR connection page with WhatsApp messaging")
}
if !strings.Contains(strconv.FormatInt(clientID, 10), "") {
t.Log("client_id passed to page")
}
}
func TestLeadsQRPolling(t *testing.T) {
testDB, cleanup := SetupTestDB(t)
defer cleanup()
DB = testDB
WAConnector = whatsapp.NewFakeConnector()
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
testDB.Exec(
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
"test@example.com", "Test Account", string(hashedPassword), time.Now().Unix(),
)
var accountID int64
testDB.QueryRow("SELECT account_id FROM accounts WHERE email = ?", "test@example.com").Scan(&accountID)
sessionID := "test-session"
testDB.Exec(
"INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)",
sessionID, accountID, time.Now().Add(time.Hour).Unix(),
)
var clientID int64
testDB.QueryRow(
"INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?) RETURNING client_id",
accountID, "Test Client", "+5521987654321", time.Now().Unix(),
).Scan(&clientID)
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
w := httptest.NewRecorder()
LeadsQR(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
}

View File

@@ -49,19 +49,39 @@ func ListCustomers(w http.ResponseWriter, r *http.Request) {
}
func CreateCustomer(w http.ResponseWriter, r *http.Request) {
accountID, ok := requireAuth(w, r)
isInternal := r.Header.Get("X-Internal-Secret") == "internal-secret"
var accountID int64
var clientID int64
var ok bool
if isInternal {
clientID, _ = strconv.ParseInt(r.FormValue("client_id"), 10, 64)
accountID = clientID
ok = true
} else {
accountID, ok = requireAuth(w, r)
if ok {
clientID, _ = strconv.ParseInt(r.FormValue("client_id"), 10, 64)
}
}
if !ok {
return
}
r.ParseForm()
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
var checkID int64
err := DB.QueryRow("SELECT client_id FROM clients WHERE client_id = ? AND account_id = ?", clientID, accountID).Scan(&checkID)
if err != nil {
http.Error(w, "Invalid client", http.StatusBadRequest)
return
if clientID == 0 {
clientID, _ = strconv.ParseInt(r.FormValue("client_id"), 10, 64)
}
if !isInternal {
var checkID int64
err := DB.QueryRow("SELECT client_id FROM clients WHERE client_id = ? AND account_id = ?", clientID, accountID).Scan(&checkID)
if err != nil {
http.Error(w, "Invalid client", http.StatusBadRequest)
return
}
}
customer := db.Customer{
@@ -73,6 +93,24 @@ func CreateCustomer(w http.ResponseWriter, r *http.Request) {
CreatedAt: time.Now().Unix(),
}
if phone := r.FormValue("phone"); phone != "" {
var existingID int64
err := DB.QueryRow(
"SELECT customer_id FROM customers WHERE client_id = ? AND phone = ?",
clientID, phone,
).Scan(&existingID)
if err == nil {
_, err = DB.Exec(
"UPDATE customers SET name = ?, birth_date = ?, instagram = ? WHERE customer_id = ?",
r.FormValue("name"), r.FormValue("birth_date"), r.FormValue("instagram"), existingID,
)
if err == nil {
w.Header().Set("HX-Refresh", "true")
return
}
}
}
if err := customer.Create(DB); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return

View File

@@ -0,0 +1,49 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
func TestCreateCustomerInternalSecret(t *testing.T) {
testDB, cleanup := SetupTestDB(t)
defer cleanup()
DB = testDB
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
testDB.Exec(
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
"test@example.com", "Test Account", string(hashedPassword), time.Now().Unix(),
)
var accountID int64
testDB.QueryRow("SELECT account_id FROM accounts WHERE email = ?", "test@example.com").Scan(&accountID)
var clientID int64
testDB.QueryRow(
"INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?) RETURNING client_id",
accountID, "Test Client", "+5521987654321", time.Now().Unix(),
).Scan(&clientID)
req := httptest.NewRequest(http.MethodPost, "/customers", nil)
req.PostForm = map[string][]string{
"name": {"+5521987654321"},
"phone": {"+5521987654321"},
"client_id": {string(rune(clientID))},
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Internal-Secret", "internal-secret")
w := httptest.NewRecorder()
CreateCustomer(w, req)
if w.Code != http.StatusOK && w.Code != http.StatusFound {
t.Fatalf("expected success with internal secret, got %d", w.Code)
}
}

View File

@@ -0,0 +1,313 @@
package handlers
import (
"context"
"encoding/json"
"log"
"net/http"
"strconv"
"time"
"go-crm/internal/db"
"go-crm/internal/whatsapp"
"github.com/go-chi/chi/v5"
)
func ListLeads(w http.ResponseWriter, r *http.Request) {
accountID, ok := requireAuth(w, r)
if !ok {
return
}
search := r.URL.Query().Get("search")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if limit == 0 {
limit = 20
}
query := "SELECT customer_id, client_id, name, phone, birth_date, instagram, created_at FROM customers WHERE client_id IN (SELECT client_id FROM clients WHERE account_id = ?)"
args := []interface{}{accountID}
if search != "" {
query += " AND (name LIKE ? OR phone LIKE ?)"
searchPat := "%" + search + "%"
args = append(args, searchPat, searchPat)
}
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := DB.Query(query, args...)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var customers []db.Customer
for rows.Next() {
var c db.Customer
if err := rows.Scan(&c.CustomerID, &c.ClientID, &c.Name, &c.Phone, &c.BirthDate, &c.Instagram, &c.CreatedAt); err != nil {
continue
}
customers = append(customers, c)
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head>
<title>Leads</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: sans-serif; padding: 1rem; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; }
.search-box { margin-bottom: 1rem; }
.edit-row { display: none; }
</style>
</head>
<body>
<h1>Leads</h1>
<div class="search-box">
<form hx-get="/leads" hx-target="#leadList" hx-swap="innerHTML">
<input type="text" name="search" placeholder="Search by name or phone" value="` + search + `">
<button type="submit">Search</button>
</form>
</div>
<table>
<thead>
<tr><th>Name</th><th>Phone</th><th>Birth Date</th><th>Instagram</th><th>Actions</th></tr>
</thead>
<tbody id="leadList">
`))
for _, c := range customers {
w.Write([]byte(`<tr>
<td>` + c.Name + `</td>
<td>` + c.Phone + `</td>
<td>` + c.BirthDate + `</td>
<td>` + c.Instagram + `</td>
<td>
<button type="button" onclick="document.getElementById('editLead` + strconv.FormatInt(c.CustomerID, 10) + `').style.display='table-row'">Edit</button>
<form method="DELETE" style="display:inline" hx-delete="/leads/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="closest tr">
<button type="submit">Delete</button>
</form>
</td>
</tr>
<tr id="editLead` + strconv.FormatInt(c.CustomerID, 10) + `" class="edit-row">
<td colspan="5">
<form hx-put="/leads/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="#leadList" hx-swap="innerHTML">
<input type="text" name="name" value="` + c.Name + `">
<input type="tel" name="phone" value="` + c.Phone + `">
<input type="date" name="birth_date" value="` + c.BirthDate + `">
<input type="text" name="instagram" value="` + c.Instagram + `">
<button type="submit">Save</button>
</form>
</td>
</tr>`))
}
w.Write([]byte(`</tbody></table>
<p><a href="/clients">Back to Clients</a></p>
</body></html>`))
}
func UpdateLead(w http.ResponseWriter, r *http.Request) {
accountID, ok := requireAuth(w, r)
if !ok {
return
}
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
r.ParseForm()
_, err := DB.Exec(
"UPDATE customers SET name = ?, phone = ?, birth_date = ?, instagram = ? WHERE customer_id = ? AND client_id IN (SELECT client_id FROM clients WHERE account_id = ?)",
r.FormValue("name"), r.FormValue("phone"), r.FormValue("birth_date"), r.FormValue("instagram"), id, accountID,
)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ListLeads(w, r)
}
func DeleteLead(w http.ResponseWriter, r *http.Request) {
accountID, ok := requireAuth(w, r)
if !ok {
return
}
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
_, err := DB.Exec(
"DELETE FROM customers WHERE customer_id = ? AND client_id IN (SELECT client_id FROM clients WHERE account_id = ?)",
id, accountID,
)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte("OK"))
}
func LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
accountID, ok := requireAuth(w, r)
if !ok {
return
}
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
client, err := db.GetClientByID(DB, accountID, clientID)
if err != nil {
http.Error(w, "Client not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head>
<title>Connect WhatsApp</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style>
body { font-family: sans-serif; padding: 2rem; text-align: center; }
#qrcode { margin: 2rem auto; display: flex; justify-content: center; }
#status { padding: 1rem; }
</style>
</head>
<body>
<h1>Connect WhatsApp</h1>
<p>Scan the QR code below with your WhatsApp app to connect</p>
<div id="qrcode"></div>
<p id="status">Loading...</p>
<script>
var lastQR = '';
function pollQR() {
fetch('/leads/qr?client_id=` + strconv.FormatInt(client.ClientID, 10) + `')
.then(r => r.json())
.then(data => {
if (data.qr) {
// Only re-render QR if the code actually changed.
if (data.qr !== lastQR) {
lastQR = data.qr;
document.getElementById('qrcode').innerHTML = '';
new QRCode(document.getElementById('qrcode'), {
text: data.qr,
width: 256,
height: 256
});
}
document.getElementById('status').textContent = 'Scan with WhatsApp';
setTimeout(pollQR, 5000);
} else if (data.status === 'ready') {
document.getElementById('status').textContent = 'Connected!';
document.getElementById('qrcode').innerHTML = '&#10003;';
// Stop polling — connected.
} else if (data.status === 'error') {
document.getElementById('status').textContent = 'Error: ' + (data.error || 'Unknown') + ' — retrying...';
// Back off longer on error to let server recover.
setTimeout(pollQR, 8000);
} else {
document.getElementById('status').textContent = 'Status: ' + data.status;
setTimeout(pollQR, 5000);
}
})
.catch(err => {
document.getElementById('status').textContent = 'Connection error — retrying...';
setTimeout(pollQR, 5000);
});
}
pollQR();
</script>
<p><a href="/clients">Back to Clients</a></p>
</body></html>`))
}
func jsonEscape(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func LeadsQR(w http.ResponseWriter, r *http.Request) {
accountID, ok := requireAuth(w, r)
if !ok {
return
}
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
client, err := db.GetClientByID(DB, accountID, clientID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"status":"error","error":"client not found"}`))
return
}
if WAConnector == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(`{"status":"error","error":"WhatsApp not configured - contact admin"}`))
return
}
connected, err := WAConnector.IsConnected(r.Context(), clientID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(err.Error()) + `}`))
return
}
if connected {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"ready"}`))
return
}
w.Header().Set("Content-Type", "application/json")
log.Printf("QR: Starting Connect for client %d", clientID)
// Use context.Background() so the WA session goroutine outlives this HTTP request.
// The handler returns after the first QR frame; subsequent polls reuse the same session.
qrChan, err := WAConnector.Connect(context.Background(), clientID)
if err != nil {
log.Printf("QR: Connect returned error for client %d: %v", clientID, err)
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(err.Error()) + `}`))
return
}
timeout := time.After(30 * time.Second)
for {
select {
case <-timeout:
log.Printf("QR: Timeout waiting for client %d", clientID)
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape("timeout waiting for QR code") + `}`))
return
case frame, ok := <-qrChan:
if !ok {
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":"connection closed"}`))
return
}
if frame.QR != "" {
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"qr":` + jsonEscape(frame.QR) + `,"status":"waiting"}`))
return
}
if frame.State == whatsapp.StateConnected {
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"ready"}`))
return
}
if frame.State == whatsapp.StateFailed {
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(frame.Error) + `}`))
return
}
}
}
}

View File

@@ -0,0 +1,245 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"go-crm/internal/whatsapp"
"golang.org/x/crypto/bcrypt"
)
func newQRTestEnv(t *testing.T) (clientID int64, sessionID string, cleanup func()) {
t.Helper()
testDB, cleanupDB := SetupTestDB(t)
DB = testDB
WAConnector = whatsapp.NewFakeConnector()
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
testDB.Exec(
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
"qr-test@example.com", "QR Test Account", string(hashedPassword), time.Now().Unix(),
)
var accountID int64
testDB.QueryRow("SELECT account_id FROM accounts WHERE email = ?", "qr-test@example.com").Scan(&accountID)
sessionID = "qr-test-session"
testDB.Exec(
"INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)",
sessionID, accountID, time.Now().Add(time.Hour).Unix(),
)
testDB.QueryRow(
"INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?) RETURNING client_id",
accountID, "QR Test Client", "+5521999999999", time.Now().Unix(),
).Scan(&clientID)
return clientID, sessionID, cleanupDB
}
func TestLeadsQR_FirstPollReturnsQRCode(t *testing.T) {
clientID, sessionID, cleanup := newQRTestEnv(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
w := httptest.NewRecorder()
LeadsQR(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v — body: %s", err, w.Body.String())
}
if resp["qr"] == nil || resp["qr"] == "" {
t.Errorf("expected non-empty qr field, got: %v", resp)
}
if resp["status"] != "waiting" {
t.Errorf("expected status=waiting, got: %v", resp["status"])
}
}
func TestLeadsQR_AlreadyConnectedReturnsReady(t *testing.T) {
clientID, sessionID, cleanup := newQRTestEnv(t)
defer cleanup()
// Mark the client as connected in the fake connector.
WAConnector.(*whatsapp.FakeConnector).MarkConnected(clientID)
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
w := httptest.NewRecorder()
LeadsQR(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if resp["status"] != "ready" {
t.Errorf("expected status=ready, got: %v", resp["status"])
}
if resp["qr"] != nil {
t.Errorf("expected no qr field when already connected, got: %v", resp["qr"])
}
}
func TestLeadsQR_ConnectorErrorReturnsErrorStatus(t *testing.T) {
clientID, sessionID, cleanup := newQRTestEnv(t)
defer cleanup()
fake := whatsapp.NewFakeConnector()
fake.SetConnectError(fmt.Errorf("connector unavailable"))
WAConnector = fake
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
w := httptest.NewRecorder()
LeadsQR(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if resp["status"] != "error" {
t.Errorf("expected status=error, got: %v", resp["status"])
}
if resp["error"] == nil || resp["error"] == "" {
t.Errorf("expected non-empty error field")
}
}
func TestLeadsQR_UnknownClientReturnsNotFound(t *testing.T) {
_, sessionID, cleanup := newQRTestEnv(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id=99999", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
w := httptest.NewRecorder()
LeadsQR(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestLeadsQR_NoSessionReturnsUnauthorized(t *testing.T) {
clientID, _, cleanup := newQRTestEnv(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
// No session cookie.
w := httptest.NewRecorder()
LeadsQR(w, req)
// /leads/qr is API-mode: returns JSON {"error":"unauthorized"}, not a redirect.
body := w.Body.String()
if !strings.Contains(body, "unauthorized") {
t.Errorf("expected unauthorized in response body, got: %s", body)
}
// Must not return a QR code or ready status.
if strings.Contains(body, `"qr"`) || strings.Contains(body, `"ready"`) {
t.Errorf("unauthenticated response must not contain qr or ready: %s", body)
}
}
func TestLeadsQR_WAConnectorNilReturns503(t *testing.T) {
clientID, sessionID, cleanup := newQRTestEnv(t)
defer cleanup()
WAConnector = nil
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
w := httptest.NewRecorder()
LeadsQR(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d: %s", w.Code, w.Body.String())
}
}
func TestLeadsQR_SecondPollBeforeScanStillReturnsQR(t *testing.T) {
clientID, sessionID, cleanup := newQRTestEnv(t)
defer cleanup()
// Pre-queue two QR frames so both polls have something to read from the shared channel.
fake := WAConnector.(*whatsapp.FakeConnector)
fake.QueueQR(clientID,
whatsapp.QRFrame{QR: "qr-code-poll-1", State: whatsapp.StateWaitingQR},
whatsapp.QRFrame{QR: "qr-code-poll-2", State: whatsapp.StateWaitingQR},
)
// First poll.
req1 := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
req1.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
w1 := httptest.NewRecorder()
LeadsQR(w1, req1)
var resp1 map[string]interface{}
json.Unmarshal(w1.Body.Bytes(), &resp1)
if resp1["status"] != "waiting" {
t.Fatalf("first poll: expected waiting, got %v — body: %s", resp1["status"], w1.Body.String())
}
// Second poll — same channel reused, second QR frame available.
req2 := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
req2.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
w2 := httptest.NewRecorder()
LeadsQR(w2, req2)
var resp2 map[string]interface{}
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("second poll: invalid JSON: %v — body: %s", err, w2.Body.String())
}
if resp2["status"] == "ready" {
t.Errorf("second poll: got ready before scan — IsConnected/IsLoggedIn mismatch bug present")
}
if resp2["qr"] == nil || resp2["qr"] == "" {
t.Errorf("second poll: expected QR code, got: %v", resp2)
}
}
func TestLeadsQR_QRCodeResponseContainsClientID(t *testing.T) {
clientID, sessionID, cleanup := newQRTestEnv(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
w := httptest.NewRecorder()
LeadsQR(w, req)
body := w.Body.String()
if !strings.Contains(body, strconv.FormatInt(clientID, 10)) {
t.Errorf("response missing client_id: %s", body)
}
}

View File

@@ -3,10 +3,15 @@ package handlers
import (
"database/sql"
"time"
"go-crm/internal/whatsapp"
)
func SetupHandlers(db *sql.DB) {
var WAConnector whatsapp.Connector
func SetupHandlers(db *sql.DB, wa whatsapp.Connector) {
DB = db
WAConnector = wa
}
func getCurrentTimestamp() int64 {