To gitea and beyond, let's go(-yco)

This commit is contained in:
2025-11-10 19:12:09 +01:00
parent 8f6133392d
commit 71a031342b
245 changed files with 83994 additions and 0 deletions

538
internal/testutils/email.go Normal file
View File

@@ -0,0 +1,538 @@
package testutils
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/joho/godotenv"
"goyco/internal/config"
"goyco/internal/database"
)
type TestEmailServer struct {
listener net.Listener
port int
emails []TestEmail
shouldFail bool
delay time.Duration
closed bool
mu sync.RWMutex
}
type TestEmail struct {
From string
To []string
Subject string
Body string
Headers map[string]string
Raw string
}
func NewTestEmailServer() (*TestEmailServer, error) {
listener, err := net.Listen("tcp", ":0")
if err != nil {
return nil, err
}
server := &TestEmailServer{
listener: listener,
emails: make([]TestEmail, 0),
delay: 0,
closed: false,
}
addr := listener.Addr().(*net.TCPAddr)
server.port = addr.Port
go server.serve()
return server, nil
}
func (s *TestEmailServer) serve() {
for {
if s.closed {
return
}
conn, err := s.listener.Accept()
if err != nil {
if !s.closed {
}
return
}
go s.handleConnection(conn)
}
}
func (s *TestEmailServer) handleConnection(conn net.Conn) {
defer conn.Close()
conn.Write([]byte("220 Test SMTP server ready\r\n"))
buffer := make([]byte, 1024)
for {
n, err := conn.Read(buffer)
if err != nil {
return
}
command := strings.TrimSpace(string(buffer[:n]))
if s.delay > 0 {
time.Sleep(s.delay)
}
switch {
case strings.HasPrefix(command, "EHLO"), strings.HasPrefix(command, "HELO"):
conn.Write([]byte("250-Hello\r\n250-AUTH PLAIN LOGIN\r\n250 OK\r\n"))
case strings.HasPrefix(command, "AUTH PLAIN"):
conn.Write([]byte("235 Authentication successful\r\n"))
case strings.HasPrefix(command, "AUTH LOGIN"):
conn.Write([]byte("334 VXNlcm5hbWU6\r\n"))
if _, err := conn.Read(buffer); err != nil {
return
}
conn.Write([]byte("334 UGFzc3dvcmQ6\r\n"))
if _, err := conn.Read(buffer); err != nil {
return
}
conn.Write([]byte("235 Authentication successful\r\n"))
case strings.HasPrefix(command, "AUTH"):
conn.Write([]byte("504 Unrecognized authentication type\r\n"))
case strings.HasPrefix(command, "MAIL FROM"):
if s.shouldFail {
conn.Write([]byte("550 Mail from failed\r\n"))
return
}
conn.Write([]byte("250 OK\r\n"))
case strings.HasPrefix(command, "RCPT TO"):
if s.shouldFail {
conn.Write([]byte("550 Rcpt to failed\r\n"))
return
}
conn.Write([]byte("250 OK\r\n"))
case command == "DATA":
conn.Write([]byte("354 Start mail input; end with <CRLF>.<CRLF>\r\n"))
s.readEmailData(conn)
case command == "QUIT":
conn.Write([]byte("221 Bye\r\n"))
return
default:
conn.Write([]byte("500 Unknown command\r\n"))
}
}
}
func (s *TestEmailServer) readEmailData(conn net.Conn) {
var emailData strings.Builder
buffer := make([]byte, 1024)
for {
n, err := conn.Read(buffer)
if err != nil {
return
}
emailData.Write(buffer[:n])
if strings.Contains(emailData.String(), "\r\n.\r\n") {
break
}
}
email := s.parseEmail(emailData.String())
s.mu.Lock()
s.emails = append(s.emails, email)
s.mu.Unlock()
conn.Write([]byte("250 OK\r\n"))
}
func (s *TestEmailServer) parseEmail(data string) TestEmail {
lines := strings.Split(data, "\r\n")
email := TestEmail{
Headers: make(map[string]string),
Raw: data,
}
for _, line := range lines {
if strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
email.Headers[key] = value
switch key {
case "From":
email.From = value
case "To":
email.To = []string{value}
case "Subject":
email.Subject = value
}
}
} else if line == "" {
bodyStart := strings.Index(data, "\r\n\r\n")
if bodyStart != -1 {
email.Body = data[bodyStart+4:]
email.Body = strings.TrimSuffix(email.Body, "\r\n.\r\n")
}
break
}
}
return email
}
func (s *TestEmailServer) Close() error {
s.closed = true
return s.listener.Close()
}
func (s *TestEmailServer) GetPort() int {
return s.port
}
func (s *TestEmailServer) GetEmails() []TestEmail {
s.mu.RLock()
defer s.mu.RUnlock()
return s.emails
}
func (s *TestEmailServer) ClearEmails() {
s.mu.Lock()
defer s.mu.Unlock()
s.emails = make([]TestEmail, 0)
}
func (s *TestEmailServer) SetShouldFail(shouldFail bool) {
s.shouldFail = shouldFail
}
func (s *TestEmailServer) SetDelay(delay time.Duration) {
s.delay = delay
}
func (s *TestEmailServer) GetEmailCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.emails)
}
func (s *TestEmailServer) GetLastEmail() *TestEmail {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.emails) == 0 {
return nil
}
return &s.emails[len(s.emails)-1]
}
func (s *TestEmailServer) WaitForEmails(count int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
s.mu.RLock()
emailCount := len(s.emails)
s.mu.RUnlock()
if emailCount >= count {
return true
}
time.Sleep(10 * time.Millisecond)
}
return false
}
type TestEmailValidator struct{}
func NewTestEmailValidator() *TestEmailValidator {
return &TestEmailValidator{}
}
func (v *TestEmailValidator) ValidateEmail(email *TestEmail, expectedTo, expectedSubject, expectedBody string) []string {
var errors []string
if email == nil {
errors = append(errors, "email is nil")
return errors
}
if len(email.To) == 0 {
errors = append(errors, "no recipients")
} else if email.To[0] != expectedTo {
errors = append(errors, fmt.Sprintf("to = %v, want %v", email.To[0], expectedTo))
}
if email.Subject != expectedSubject {
errors = append(errors, fmt.Sprintf("subject = %v, want %v", email.Subject, expectedSubject))
}
if email.Body != expectedBody {
errors = append(errors, fmt.Sprintf("body = %v, want %v", email.Body, expectedBody))
}
return errors
}
func (v *TestEmailValidator) ValidateEmailContains(email *TestEmail, expectedTo, expectedSubjectContains, expectedBodyContains string) []string {
var errors []string
if email == nil {
errors = append(errors, "email is nil")
return errors
}
if len(email.To) == 0 {
errors = append(errors, "no recipients")
} else if email.To[0] != expectedTo {
errors = append(errors, fmt.Sprintf("to = %v, want %v", email.To[0], expectedTo))
}
if !strings.Contains(email.Subject, expectedSubjectContains) {
errors = append(errors, fmt.Sprintf("subject does not contain %v", expectedSubjectContains))
}
if !strings.Contains(email.Body, expectedBodyContains) {
errors = append(errors, fmt.Sprintf("body does not contain %v", expectedBodyContains))
}
return errors
}
func (v *TestEmailValidator) ValidateEmailHeaders(email *TestEmail, expectedHeaders map[string]string) []string {
var errors []string
if email == nil {
errors = append(errors, "email is nil")
return errors
}
for key, expectedValue := range expectedHeaders {
actualValue, exists := email.Headers[key]
if !exists {
errors = append(errors, fmt.Sprintf("header %v not found", key))
} else if actualValue != expectedValue {
errors = append(errors, fmt.Sprintf("header %v = %v, want %v", key, actualValue, expectedValue))
}
}
return errors
}
type TestEmailBuilder struct {
email *TestEmail
}
func NewTestEmailBuilder() *TestEmailBuilder {
return &TestEmailBuilder{
email: &TestEmail{
Headers: make(map[string]string),
},
}
}
func (b *TestEmailBuilder) From(from string) *TestEmailBuilder {
b.email.From = from
return b
}
func (b *TestEmailBuilder) To(to string) *TestEmailBuilder {
b.email.To = []string{to}
return b
}
func (b *TestEmailBuilder) Subject(subject string) *TestEmailBuilder {
b.email.Subject = subject
return b
}
func (b *TestEmailBuilder) Body(body string) *TestEmailBuilder {
b.email.Body = body
return b
}
func (b *TestEmailBuilder) Header(key, value string) *TestEmailBuilder {
b.email.Headers[key] = value
return b
}
func (b *TestEmailBuilder) Build() *TestEmail {
return b.email
}
type TestEmailMatcher struct{}
func NewTestEmailMatcher() *TestEmailMatcher {
return &TestEmailMatcher{}
}
func (m *TestEmailMatcher) MatchEmail(email *TestEmail, criteria map[string]any) bool {
if email == nil {
return false
}
for key, expectedValue := range criteria {
switch key {
case "from":
if email.From != expectedValue {
return false
}
case "to":
if len(email.To) == 0 || email.To[0] != expectedValue {
return false
}
case "subject":
if email.Subject != expectedValue {
return false
}
case "body":
if email.Body != expectedValue {
return false
}
case "subject_contains":
if !strings.Contains(email.Subject, expectedValue.(string)) {
return false
}
case "body_contains":
if !strings.Contains(email.Body, expectedValue.(string)) {
return false
}
case "header":
headerMap := expectedValue.(map[string]string)
for headerKey, headerValue := range headerMap {
if email.Headers[headerKey] != headerValue {
return false
}
}
}
}
return true
}
func (m *TestEmailMatcher) FindEmail(emails []TestEmail, criteria map[string]any) *TestEmail {
for i := range emails {
if m.MatchEmail(&emails[i], criteria) {
return &emails[i]
}
}
return nil
}
func (m *TestEmailMatcher) CountMatchingEmails(emails []TestEmail, criteria map[string]any) int {
count := 0
for i := range emails {
if m.MatchEmail(&emails[i], criteria) {
count++
}
}
return count
}
type MockEmailSenderWithError struct {
Err error
}
func NewMockEmailSenderWithError(err error) *MockEmailSenderWithError {
return &MockEmailSenderWithError{Err: err}
}
func (m *MockEmailSenderWithError) Send(to, subject, body string) error {
return m.Err
}
func NewEmailTestUser(username, email string) *database.User {
return &database.User{
ID: 1,
Username: username,
Email: email,
}
}
func NewEmailTestConfig(baseURL string) *config.Config {
return &config.Config{
App: config.AppConfig{
BaseURL: baseURL,
AdminEmail: "admin@example.com",
},
}
}
type SMTPSender struct {
Host string
Port int
Username string
Password string
From string
timeout time.Duration
}
func GetSMTPSenderFromEnv(t *testing.T) *SMTPSender {
t.Helper()
envPaths := []string{".env", "../.env", "../../.env", "../../../.env"}
for _, envPath := range envPaths {
if _, err := os.Stat(envPath); err == nil {
_ = godotenv.Load(envPath)
break
}
}
host := strings.TrimSpace(os.Getenv("SMTP_HOST"))
if host == "" {
t.Skip("Skipping SMTP integration tests: SMTP_HOST is not configured")
}
portStr := strings.TrimSpace(os.Getenv("SMTP_PORT"))
if portStr == "" {
t.Skip("Skipping SMTP integration tests: SMTP_PORT is not configured")
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Skipf("Skipping SMTP integration tests: invalid SMTP_PORT '%s': %v", portStr, err)
}
from := strings.TrimSpace(os.Getenv("SMTP_FROM"))
if from == "" {
t.Skip("Skipping SMTP integration tests: SMTP_FROM is not configured")
}
sender := &SMTPSender{
Host: host,
Port: port,
Username: os.Getenv("SMTP_USERNAME"),
Password: os.Getenv("SMTP_PASSWORD"),
From: from,
timeout: 5 * time.Second,
}
address := net.JoinHostPort(sender.Host, strconv.Itoa(sender.Port))
connexion, err := net.DialTimeout("tcp", address, 3*time.Second)
if err != nil {
t.Skipf("Skipping SMTP integration tests: unable to reach %s: %v", address, err)
}
connexion.Close()
return sender
}
func (s *SMTPSender) Send(to, subject, body string) error {
if to == "" {
return fmt.Errorf("recipient email is required")
}
if subject == "" {
return fmt.Errorf("subject is required")
}
if body == "" {
return fmt.Errorf("body is required")
}
return nil
}