feat(go-crm): full auth, routing, middleware, and supporting infra

- Add auth handlers (signup, login, logout, account management) with bcrypt
- Add client, customer, service, scheduling, payment, question, answer handlers
- Add dashboard, monthly report, and lead pipeline pages
- Add UTF-8 middleware to force charset on HTML responses
- Add config package with env-based overrides for DB path, secrets, endpoints
- Add parser package for WhatsApp message ingestion
- Add clean-arch layers: pkg/domain, pkg/repo, pkg/usecase for leads
- Add cmd/migrate utility for DB migrations
- Add Makefile, README, run-tests.sh, and dev scripts
- Update docker-compose.yml with memory limits
- Update .air.toml to exclude DB files and stop on errors
- Update whatsapp-sync dependencies and add src/index.js entrypoint
- Add whatsme standalone WhatsApp reader app (source only)
- Untrack .opencode-sandbox/data/go-crm.db from git history
- Expand root .gitignore: ngrok, tmp dirs, sandbox DBs, compiled binaries
This commit is contained in:
2026-05-23 16:55:55 -03:00
parent 57920d45d6
commit 744868caa1
52 changed files with 4868 additions and 1068 deletions

View File

@@ -0,0 +1,165 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"go-crm/internal/db"
"go-crm/internal/whatsapp"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
)
func TestViewClientShowsWhatsAppInfo(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()
fakeWA := whatsapp.NewFakeConnector()
fakeWA.MarkConnected(1)
DB = testDB
WAConnector = fakeWA
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)
}
var clientID int64
testDB.QueryRow(
"INSERT INTO clients (account_id, name, phone, whatsapp_number, whatsapp_connected, created_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING client_id",
accountID, "WA Client", "+5521987654321", "+5521987654321", 1, time.Now().Unix(),
).Scan(&clientID)
sessionID := "test-session-viewclient"
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)
}
req := httptest.NewRequest(http.MethodGet, "/clients/"+strconv.FormatInt(clientID, 10), nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", strconv.FormatInt(clientID, 10))
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
ViewClient(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "+5521987654321") {
t.Error("expected ViewClient to show WhatsApp number")
}
if !strings.Contains(body, "WhatsApp") {
t.Error("expected ViewClient to show WhatsApp label")
}
}
func TestViewClientShowsNotConnected(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)
}
var clientID int64
testDB.QueryRow(
"INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?) RETURNING client_id",
accountID, "No WA Client", "+5521987654322", time.Now().Unix(),
).Scan(&clientID)
sessionID := "test-session-viewclient-no-wa"
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)
}
req := httptest.NewRequest(http.MethodGet, "/clients/"+strconv.FormatInt(clientID, 10), nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", strconv.FormatInt(clientID, 10))
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
ViewClient(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "WhatsApp") {
t.Error("expected ViewClient to show WhatsApp section even when not connected")
}
if !strings.Contains(body, "Connect") {
t.Error("expected ViewClient to show Connect link when not connected")
}
}