Projeto bot pontos
This commit is contained in:
290
backend/internal/adapters/pontomais/client.go
Normal file
290
backend/internal/adapters/pontomais/client.go
Normal file
@@ -0,0 +1,290 @@
|
||||
// Package pontomais é o adapter de saída para a API da PontoMais.
|
||||
//
|
||||
// Contratos confirmados no Modelo: POST /api/auth/sign_in devolve token e
|
||||
// client_id; GET /api/time_cards/current/last_cached devolve os registros
|
||||
// recentes. A API não informa a validade do token: a sessão fica só em
|
||||
// memória e é renovada quando o histórico responde 401/403.
|
||||
package pontomais
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAuthRejected: a PontoMais recusou o login (credencial inválida ou bloqueada).
|
||||
ErrAuthRejected = errors.New("a PontoMais recusou a credencial")
|
||||
)
|
||||
|
||||
// Decrypter decifra a senha guardada; satisfeito por *crypto.Keyring.
|
||||
type Decrypter interface {
|
||||
Decrypt(data []byte, version int16) (string, error)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
authURL string
|
||||
historyURL string
|
||||
keys Decrypter
|
||||
retryDelay time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[int64]cachedSession
|
||||
}
|
||||
|
||||
type session struct {
|
||||
client string
|
||||
token string
|
||||
uid string
|
||||
}
|
||||
|
||||
type cachedSession struct {
|
||||
session session
|
||||
credentialsUpdated time.Time
|
||||
}
|
||||
|
||||
func NewClient(authURL, historyURL string, timeout time.Duration, keys Decrypter) *Client {
|
||||
return &Client{
|
||||
http: &http.Client{Timeout: timeout},
|
||||
authURL: authURL,
|
||||
historyURL: historyURL,
|
||||
keys: keys,
|
||||
retryDelay: 3 * time.Second,
|
||||
sessions: make(map[int64]cachedSession),
|
||||
}
|
||||
}
|
||||
|
||||
// History devolve os pontos recentes do usuário. Faz uma nova tentativa rápida
|
||||
// em falhas transitórias (rede, timeout, 5xx) e refaz o login uma vez após 401/403.
|
||||
func (c *Client) History(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error) {
|
||||
points, err := c.historyOnce(ctx, cred, loc)
|
||||
if err == nil || !isTransient(err) {
|
||||
return points, err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(c.retryDelay):
|
||||
}
|
||||
return c.historyOnce(ctx, cred, loc)
|
||||
}
|
||||
|
||||
func (c *Client) historyOnce(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error) {
|
||||
s, ok := c.cached(cred)
|
||||
if !ok {
|
||||
password, err := c.keys.Decrypt(cred.EncryptedPassword, cred.KeyVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s, err = c.signIn(ctx, cred.APIEmail, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.store(cred, s)
|
||||
}
|
||||
|
||||
points, err := c.fetchHistory(ctx, s, loc)
|
||||
if !errors.Is(err, errUnauthorized) {
|
||||
return points, err
|
||||
}
|
||||
|
||||
// Token recusado: descarta a sessão e tenta uma vez com login novo.
|
||||
c.Invalidate(cred.UserID)
|
||||
password, err := c.keys.Decrypt(cred.EncryptedPassword, cred.KeyVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s, err = c.signIn(ctx, cred.APIEmail, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.store(cred, s)
|
||||
points, err = c.fetchHistory(ctx, s, loc)
|
||||
if errors.Is(err, errUnauthorized) {
|
||||
return nil, fmt.Errorf("%w: token recusado mesmo após novo login", ErrAuthRejected)
|
||||
}
|
||||
return points, err
|
||||
}
|
||||
|
||||
// Validate testa uma credencial ainda não gravada: login + uma leitura do histórico.
|
||||
func (c *Client) Validate(ctx context.Context, email, password string, loc *time.Location) error {
|
||||
s, err := c.signIn(ctx, email, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.fetchHistory(ctx, s, loc)
|
||||
if errors.Is(err, errUnauthorized) {
|
||||
return fmt.Errorf("%w: histórico recusou o token", ErrAuthRejected)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate descarta a sessão em memória (ex.: credencial alterada).
|
||||
func (c *Client) Invalidate(userID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.sessions, userID)
|
||||
}
|
||||
|
||||
func (c *Client) cached(cred domain.Credential) (session, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
item, ok := c.sessions[cred.UserID]
|
||||
if !ok || !item.credentialsUpdated.Equal(cred.UpdatedAt) {
|
||||
return session{}, false
|
||||
}
|
||||
return item.session, true
|
||||
}
|
||||
|
||||
func (c *Client) store(cred domain.Credential, s session) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.sessions[cred.UserID] = cachedSession{session: s, credentialsUpdated: cred.UpdatedAt}
|
||||
}
|
||||
|
||||
type authRequest struct {
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
Token string `json:"token"`
|
||||
ClientID string `json:"client_id"`
|
||||
Data struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) signIn(ctx context.Context, email, password string) (session, error) {
|
||||
body, err := json.Marshal(authRequest{Login: email, Password: password})
|
||||
if err != nil {
|
||||
return session{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return session{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return session{}, networkError("login", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden ||
|
||||
resp.StatusCode == http.StatusUnprocessableEntity:
|
||||
return session{}, fmt.Errorf("%w (login HTTP %d)", ErrAuthRejected, resp.StatusCode)
|
||||
case resp.StatusCode < 200 || resp.StatusCode > 299:
|
||||
return session{}, &statusError{op: "login", code: resp.StatusCode}
|
||||
}
|
||||
|
||||
var payload authResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return session{}, fmt.Errorf("resposta de login ilegível: %w", err)
|
||||
}
|
||||
if payload.Token == "" || payload.ClientID == "" {
|
||||
return session{}, errors.New("resposta de login sem token ou client_id")
|
||||
}
|
||||
uid := payload.Data.Login
|
||||
if uid == "" {
|
||||
uid = email
|
||||
}
|
||||
return session{client: payload.ClientID, token: payload.Token, uid: uid}, nil
|
||||
}
|
||||
|
||||
type historyResponse struct {
|
||||
TimeCards []struct {
|
||||
Date string `json:"date"`
|
||||
Time string `json:"time"`
|
||||
Disabled bool `json:"disabled"`
|
||||
} `json:"time_cards"`
|
||||
}
|
||||
|
||||
var errUnauthorized = errors.New("token não autorizado")
|
||||
|
||||
func (c *Client) fetchHistory(ctx context.Context, s session, loc *time.Location) ([]domain.Point, error) {
|
||||
if _, err := url.Parse(c.historyURL); err != nil {
|
||||
return nil, fmt.Errorf("URL do histórico inválida: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.historyURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("access-token", s.token)
|
||||
req.Header.Set("client", s.client)
|
||||
req.Header.Set("uid", s.uid)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, networkError("histórico", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return nil, &statusError{op: "histórico", code: resp.StatusCode}
|
||||
}
|
||||
|
||||
var payload historyResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("resposta de histórico ilegível: %w", err)
|
||||
}
|
||||
|
||||
points := make([]domain.Point, 0, len(payload.TimeCards))
|
||||
for _, raw := range payload.TimeCards {
|
||||
if raw.Disabled {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(raw.Date) + " " + strings.TrimSpace(raw.Time)
|
||||
occurredAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, loc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: horário %q", domain.ErrInvalidPointHistory, value)
|
||||
}
|
||||
points = append(points, domain.Point{OccurredAt: occurredAt})
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
type statusError struct {
|
||||
op string
|
||||
code int
|
||||
}
|
||||
|
||||
func (e *statusError) Error() string { return fmt.Sprintf("PontoMais: %s retornou HTTP %d", e.op, e.code) }
|
||||
|
||||
type transientError struct{ err error }
|
||||
|
||||
func (e *transientError) Error() string { return e.err.Error() }
|
||||
func (e *transientError) Unwrap() error { return e.err }
|
||||
|
||||
// networkError remove a URL do erro (por segurança e legibilidade) e marca como transitório.
|
||||
func networkError(op string, err error) error {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
err = urlErr.Err
|
||||
}
|
||||
return &transientError{err: fmt.Errorf("PontoMais: falha de rede no %s: %w", op, err)}
|
||||
}
|
||||
|
||||
func isTransient(err error) bool {
|
||||
var t *transientError
|
||||
if errors.As(err, &t) {
|
||||
return true
|
||||
}
|
||||
var s *statusError
|
||||
return errors.As(err, &s) && (s.code >= 500 || s.code == http.StatusTooManyRequests)
|
||||
}
|
||||
141
backend/internal/adapters/pontomais/client_test.go
Normal file
141
backend/internal/adapters/pontomais/client_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package pontomais
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
type plainKeys struct{}
|
||||
|
||||
func (plainKeys) Decrypt(data []byte, _ int16) (string, error) { return string(data), nil }
|
||||
|
||||
var saoPaulo = domain.LoadLocation("America/Sao_Paulo")
|
||||
|
||||
func newServer(t *testing.T, historyStatus func(call int32) int) (*httptest.Server, *int32, *int32) {
|
||||
t.Helper()
|
||||
var logins, histories int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/auth/sign_in", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&logins, 1)
|
||||
var body authRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.Password != "certa" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"token": "tok", "client_id": "cli", "data": map[string]string{"login": body.Login}})
|
||||
})
|
||||
mux.HandleFunc("GET /api/time_cards/current/last_cached", func(w http.ResponseWriter, r *http.Request) {
|
||||
call := atomic.AddInt32(&histories, 1)
|
||||
if status := historyStatus(call); status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("access-token") != "tok" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"time_cards": []map[string]any{
|
||||
{"date": "2026-10-14", "time": "08:00:00"},
|
||||
{"date": "2026-10-14", "time": "12:00:00"},
|
||||
{"date": "2026-10-14", "time": "12:30:00", "disabled": true},
|
||||
}})
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
return server, &logins, &histories
|
||||
}
|
||||
|
||||
func newClient(server *httptest.Server) *Client {
|
||||
c := NewClient(server.URL+"/api/auth/sign_in", server.URL+"/api/time_cards/current/last_cached", 2*time.Second, plainKeys{})
|
||||
c.retryDelay = time.Millisecond
|
||||
return c
|
||||
}
|
||||
|
||||
func cred(password string) domain.Credential {
|
||||
return domain.Credential{UserID: 1, APIEmail: "a@b.c", EncryptedPassword: []byte(password), UpdatedAt: time.Unix(100, 0)}
|
||||
}
|
||||
|
||||
func TestHistoryParsesAndCachesSession(t *testing.T) {
|
||||
server, logins, _ := newServer(t, func(int32) int { return http.StatusOK })
|
||||
c := newClient(server)
|
||||
|
||||
points, err := c.History(context.Background(), cred("certa"), saoPaulo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(points) != 2 {
|
||||
t.Fatalf("esperados 2 pontos (o desabilitado é ignorado), vieram %d", len(points))
|
||||
}
|
||||
if got := points[0].OccurredAt.In(saoPaulo).Format("15:04"); got != "08:00" {
|
||||
t.Errorf("primeiro ponto = %s", got)
|
||||
}
|
||||
|
||||
if _, err := c.History(context.Background(), cred("certa"), saoPaulo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *logins != 1 {
|
||||
t.Errorf("a sessão deveria ser reaproveitada; logins = %d", *logins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryRetriesTransientError(t *testing.T) {
|
||||
server, _, histories := newServer(t, func(call int32) int {
|
||||
if call == 1 {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
return http.StatusOK
|
||||
})
|
||||
if _, err := newClient(server).History(context.Background(), cred("certa"), saoPaulo); err != nil {
|
||||
t.Fatalf("a segunda tentativa deveria funcionar: %v", err)
|
||||
}
|
||||
if *histories != 2 {
|
||||
t.Errorf("esperadas 2 chamadas ao histórico, vieram %d", *histories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryRejectedCredential(t *testing.T) {
|
||||
server, _, _ := newServer(t, func(int32) int { return http.StatusOK })
|
||||
_, err := newClient(server).History(context.Background(), cred("errada"), saoPaulo)
|
||||
if !errors.Is(err, ErrAuthRejected) {
|
||||
t.Fatalf("esperado ErrAuthRejected, veio %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryRelogsAfterUnauthorized(t *testing.T) {
|
||||
server, logins, _ := newServer(t, func(call int32) int {
|
||||
if call == 2 {
|
||||
return http.StatusUnauthorized
|
||||
}
|
||||
return http.StatusOK
|
||||
})
|
||||
c := newClient(server)
|
||||
if _, err := c.History(context.Background(), cred("certa"), saoPaulo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.History(context.Background(), cred("certa"), saoPaulo); err != nil {
|
||||
t.Fatalf("deveria refazer o login após 401: %v", err)
|
||||
}
|
||||
if *logins != 2 {
|
||||
t.Errorf("esperados 2 logins, vieram %d", *logins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
server, _, _ := newServer(t, func(int32) int { return http.StatusOK })
|
||||
c := newClient(server)
|
||||
if err := c.Validate(context.Background(), "a@b.c", "certa", saoPaulo); err != nil {
|
||||
t.Fatalf("credencial certa: %v", err)
|
||||
}
|
||||
if err := c.Validate(context.Background(), "a@b.c", "errada", saoPaulo); !errors.Is(err, ErrAuthRejected) {
|
||||
t.Fatalf("credencial errada: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user