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) } }