Files
2026-06-15 21:25:57 +07:00

159 lines
4.5 KiB
Go

package service
import (
"context"
"errors"
"sync"
"testing"
"github.com/snip/backend/internal/adapter/memory"
"github.com/snip/backend/internal/domain"
)
// --- test doubles ---
type fakeHasher struct{}
func (fakeHasher) Hash(p string) (string, error) { return "h:" + p, nil }
func (fakeHasher) Compare(hash, p string) bool { return hash == "h:"+p }
type countingRecorder struct {
mu sync.Mutex
n map[string]int
}
func newCountingRecorder() *countingRecorder { return &countingRecorder{n: map[string]int{}} }
func (r *countingRecorder) Record(code string) {
r.mu.Lock()
r.n[code]++
r.mu.Unlock()
}
func (r *countingRecorder) count(code string) int {
r.mu.Lock()
defer r.mu.Unlock()
return r.n[code]
}
func newLinkSvc() *LinkService {
return NewLinkService(memory.NewLinkRepo(), memory.NewCache(), fakeHasher{})
}
// --- tests ---
func TestCreateRandomAndMemorable(t *testing.T) {
svc := newLinkSvc()
ctx := context.Background()
r, err := svc.Create(ctx, CreateInput{LongURL: "acme.com/a", Mode: domain.ModeRandom}, "")
if err != nil {
t.Fatal(err)
}
if r.Code == "" || r.LongURL != "https://acme.com/a" {
t.Fatalf("bad random link: %+v", r)
}
m, err := svc.Create(ctx, CreateInput{LongURL: "https://acme.com/b", Mode: domain.ModeMemorable}, "")
if err != nil {
t.Fatal(err)
}
if len(m.Code) < 5 {
t.Fatalf("memorable code too short: %s", m.Code)
}
}
func TestCustomRequiresAuthAndIsUnique(t *testing.T) {
svc := newLinkSvc()
ctx := context.Background()
if _, err := svc.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeCustom, CustomAlias: "promo"}, ""); !errors.Is(err, domain.ErrUnauthorized) {
t.Fatalf("anon custom should be unauthorized, got %v", err)
}
if _, err := svc.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeCustom, CustomAlias: "promo"}, "u1"); err != nil {
t.Fatal(err)
}
if _, err := svc.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeCustom, CustomAlias: "promo"}, "u1"); !errors.Is(err, domain.ErrCodeTaken) {
t.Fatalf("duplicate alias should conflict, got %v", err)
}
}
func TestCustomRejectsReservedPath(t *testing.T) {
svc := newLinkSvc()
if _, err := svc.Create(context.Background(), CreateInput{LongURL: "acme.com", Mode: domain.ModeCustom, CustomAlias: "dashboard"}, "u1"); !errors.Is(err, domain.ErrReserved) {
t.Fatalf("reserved alias should be rejected, got %v", err)
}
}
func TestInvalidURLRejected(t *testing.T) {
svc := newLinkSvc()
if _, err := svc.Create(context.Background(), CreateInput{LongURL: "not a url", Mode: domain.ModeRandom}, ""); !errors.Is(err, domain.ErrInvalidURL) {
t.Fatalf("expected ErrInvalidURL, got %v", err)
}
}
func TestPinLifecycleAndOwnership(t *testing.T) {
svc := newLinkSvc()
ctx := context.Background()
link, err := svc.Create(ctx, CreateInput{LongURL: "acme.com", Mode: domain.ModeRandom, Pin: "123456"}, "owner")
if err != nil {
t.Fatal(err)
}
if !link.HasPin {
t.Fatal("expected pin set")
}
// A different owner cannot touch it.
if _, err := svc.UpdateDestination(ctx, link.ID, "intruder", "https://evil.com"); !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("expected forbidden, got %v", err)
}
// Owner removes the pin.
updated, err := svc.SetPin(ctx, link.ID, "owner", "")
if err != nil {
t.Fatal(err)
}
if updated.HasPin {
t.Fatal("pin should be removed")
}
if err := svc.Delete(ctx, link.ID, "owner"); err != nil {
t.Fatal(err)
}
if _, err := svc.List(ctx, "owner", "", 1, 10); err != nil {
t.Fatal(err)
}
}
func TestListPaginationAndSearch(t *testing.T) {
svc := newLinkSvc()
ctx := context.Background()
for _, alias := range []string{"alpha", "beta", "gamma", "delta", "epsilon"} {
if _, err := svc.Create(ctx, CreateInput{LongURL: "acme.com/" + alias, Mode: domain.ModeCustom, CustomAlias: alias}, "owner"); err != nil {
t.Fatal(err)
}
}
// page 1 of size 2 → 2 items, total 5
p, err := svc.List(ctx, "owner", "", 1, 2)
if err != nil {
t.Fatal(err)
}
if p.Total != 5 || len(p.Items) != 2 {
t.Fatalf("want total=5 items=2, got total=%d items=%d", p.Total, len(p.Items))
}
// page 3 → 1 item
if p, _ := svc.List(ctx, "owner", "", 3, 2); len(p.Items) != 1 {
t.Fatalf("page 3 should have 1 item, got %d", len(p.Items))
}
// search narrows
if p, _ := svc.List(ctx, "owner", "alpha", 1, 10); p.Total != 1 || p.Items[0].Code != "alpha" {
t.Fatalf("search 'alpha' should match 1, got total=%d", p.Total)
}
// stats reflect all links
s, _ := svc.Stats(ctx, "owner")
if s.TotalLinks != 5 {
t.Fatalf("stats total links want 5, got %d", s.TotalLinks)
}
}