You've already forked agentic-coding-workflow
feat(user): service with login/logout/resolve/bootstrap (with unit tests)
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
// fakeRepo is an in-memory Repository implementation used by the service
|
||||
// unit tests. It deliberately stores users keyed by email and sessions keyed
|
||||
// by the string form of TokenHash so lookups stay O(1) and the test setup
|
||||
// doesn't need to reach into pgx-specific machinery.
|
||||
type fakeRepo struct {
|
||||
users map[string]*User
|
||||
sessions map[string]*Session
|
||||
}
|
||||
|
||||
func newFakeRepo() *fakeRepo {
|
||||
return &fakeRepo{users: map[string]*User{}, sessions: map[string]*Session{}}
|
||||
}
|
||||
|
||||
func (r *fakeRepo) CreateUser(_ context.Context, u *User) (*User, error) {
|
||||
r.users[u.Email] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetUserByEmail(_ context.Context, email string) (*User, error) {
|
||||
u, ok := r.users[email]
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "no user")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetUserByID(_ context.Context, id uuid.UUID) (*User, error) {
|
||||
for _, u := range r.users {
|
||||
if u.ID == id {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.New(errs.CodeNotFound, "no user")
|
||||
}
|
||||
|
||||
func (r *fakeRepo) CountUsers(_ context.Context) (int, error) { return len(r.users), nil }
|
||||
|
||||
func (r *fakeRepo) CreateSession(_ context.Context, s *Session) error {
|
||||
r.sessions[string(s.TokenHash)] = s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetSessionByTokenHash(_ context.Context, h []byte) (*Session, error) {
|
||||
s, ok := r.sessions[string(h)]
|
||||
if !ok || s.ExpiresAt.Before(time.Now()) {
|
||||
return nil, errs.New(errs.CodeUnauthorized, "no session")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) TouchSession(_ context.Context, _ []byte) error { return nil }
|
||||
|
||||
func (r *fakeRepo) DeleteSessionByTokenHash(_ context.Context, h []byte) error {
|
||||
delete(r.sessions, string(h))
|
||||
return nil
|
||||
}
|
||||
|
||||
// newServiceWithUser wires a *service backed by a fakeRepo that already
|
||||
// contains a single user with the given credentials. The returned *service
|
||||
// is the concrete type (not the Service interface) so individual tests can
|
||||
// poke at internal hooks like `now` if a future case needs it.
|
||||
func newServiceWithUser(t *testing.T, email, password string) (*service, *User) {
|
||||
t.Helper()
|
||||
repo := newFakeRepo()
|
||||
hash, err := HashPassword(password)
|
||||
require.NoError(t, err)
|
||||
u := &User{ID: uuid.New(), Email: email, DisplayName: "Test", PasswordHash: hash}
|
||||
repo.users[email] = u
|
||||
svc := NewService(repo).(*service)
|
||||
return svc, u
|
||||
}
|
||||
|
||||
func TestLogin_Success(t *testing.T) {
|
||||
svc, u := newServiceWithUser(t, "a@b.c", "password123")
|
||||
tok, gotUser, err := svc.Login(context.Background(), "a@b.c", "password123", "127.0.0.1")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, tok)
|
||||
require.Equal(t, u.ID, gotUser.ID)
|
||||
}
|
||||
|
||||
func TestLogin_BadPassword(t *testing.T) {
|
||||
svc, _ := newServiceWithUser(t, "a@b.c", "password123")
|
||||
_, _, err := svc.Login(context.Background(), "a@b.c", "wrong-pass", "ip")
|
||||
require.Error(t, err)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeUnauthorized, ae.Code)
|
||||
}
|
||||
|
||||
func TestLogin_UnknownEmail(t *testing.T) {
|
||||
svc, _ := newServiceWithUser(t, "a@b.c", "password123")
|
||||
_, _, err := svc.Login(context.Background(), "x@b.c", "x", "ip")
|
||||
require.Error(t, err)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeUnauthorized, ae.Code, "should not leak whether email exists")
|
||||
}
|
||||
|
||||
func TestResolveSession_Success(t *testing.T) {
|
||||
svc, u := newServiceWithUser(t, "a@b.c", "password123")
|
||||
tok, _, err := svc.Login(context.Background(), "a@b.c", "password123", "ip")
|
||||
require.NoError(t, err)
|
||||
|
||||
uid, ok, err := svc.ResolveSession(context.Background(), tok)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, u.ID, uid)
|
||||
}
|
||||
|
||||
func TestLogout_RemovesSession(t *testing.T) {
|
||||
svc, _ := newServiceWithUser(t, "a@b.c", "password123")
|
||||
tok, _, err := svc.Login(context.Background(), "a@b.c", "password123", "ip")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, svc.Logout(context.Background(), tok))
|
||||
|
||||
_, ok, err := svc.ResolveSession(context.Background(), tok)
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
}
|
||||
Reference in New Issue
Block a user