Files
backend_fiesta/services/inviteEveryAccount_test.go
2026-09-29 16:53:21 +05:30

338 lines
12 KiB
Go

package services
import (
"errors"
"strings"
"testing"
"nearle/models"
"nearle/repositories"
)
/*
Every account that is created with no password gets invited.
── Why this file exists ─────────────────────────────────────────────────────
There are three ways a back-office login comes into being on this platform, and
all three write `password = ''`:
- `CreateTenantUser` — the merchant, at onboarding
- `CreateUser` / `CreateStaff` — a person added to the directory afterwards
- `CreateTenantLocation` — the login a branch spawns when no operator is named
Only the first was ever invited. That was survivable while the sign-in screen
carried a "set your password" form, because the other two could use it. That form
is gone — it was an account takeover, since the probe behind it answered any
email with the userid needed to claim the account — so an uninvited account is now
one nobody can ever sign in to. It is listed, it appears in every branch picker,
and the first person to find out is whoever is standing in the shop.
So these tests are about coverage of the three paths, not about the mail. What
the message says and when sending fails is `inviteService_test.go`.
*/
// countingInviter records who was invited. `countingInvites` in
// resendInvite_test.go counts calls and nothing else; this one keeps the
// arguments, because the whole question here is who got the mail.
type countingInviter struct {
calls int
userid int
tenantid int
email string
business string
sent bool
reason string
failEvery bool
}
func (c *countingInviter) Invite(userid, tenantid int, email, businessName string) (bool, string) {
c.calls++
c.userid, c.tenantid, c.email, c.business = userid, tenantid, email, businessName
if c.failEvery {
return false, c.reason
}
return c.sent, c.reason
}
/* ── A person added to the directory ─────────────────────────────────────── */
type stubUserRepo struct {
repositories.UserRepository
newid int
err error
}
func (r *stubUserRepo) CreateUser(models.User) (int, error) {
if r.err != nil {
return 0, r.err
}
return r.newid, nil
}
func (r *stubUserRepo) GetUserById(uid int) (models.UserInfo, error) {
return models.UserInfo{Userid: uid}, nil
}
func TestANewStaffAccountIsInvited(t *testing.T) {
invites := &countingInviter{sent: true}
service := NewUserService(&stubUserRepo{newid: 7781}, invites)
_, outcome, err := service.CreateUser(models.User{
Email: "meena@rmart.example", Tenantid: 1147,
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if !outcome.Sent {
t.Fatalf("hired and not invited: %+v", outcome)
}
if invites.userid != 7781 {
t.Errorf("invited user %d, want the one that was just created (7781)", invites.userid)
}
if invites.email != "meena@rmart.example" {
t.Errorf("invitation addressed to %q", invites.email)
}
if invites.tenantid != 1147 {
// The token carries the tenant, and the mail names the business. A zero
// here is an invitation from nobody, to an account belonging to nobody.
t.Errorf("invited against tenant %d, want 1147", invites.tenantid)
}
}
func TestAStaffAccountWithOnlyAnAuthnameIsStillInvited(t *testing.T) {
// `PrepareNewAccount` copies email → authname, not the other way round, so a
// caller that filled in only the sign-in name leaves `Email` empty. That is
// the same mailbox, and refusing to write to it would strand the person over
// which of two identical fields was filled in.
invites := &countingInviter{sent: true}
service := NewUserService(&stubUserRepo{newid: 7782}, invites)
if _, outcome, err := service.CreateUser(models.User{
Authname: "arun@rmart.example", Tenantid: 1147,
}); err != nil || !outcome.Sent {
t.Fatalf("not invited: outcome=%+v err=%v", outcome, err)
}
if invites.email != "arun@rmart.example" {
t.Errorf("invitation addressed to %q, want the authname", invites.email)
}
}
func TestHiringSucceedsWhenTheInvitationDoesNot(t *testing.T) {
// The person is hired either way. A mail relay that refuses the address must
// not undo a hire — the operator resends, or corrects the address.
invites := &countingInviter{failEvery: true, reason: "mailbox full"}
service := NewUserService(&stubUserRepo{newid: 7783}, invites)
info, outcome, err := service.CreateUser(models.User{Email: "raj@rmart.example"})
if err != nil {
t.Fatalf("a failed invitation failed the hire: %v", err)
}
if info.Userid != 7783 {
t.Fatalf("the account was not created: %+v", info)
}
if outcome.Sent || outcome.Reason != "mailbox full" {
t.Fatalf("the reason was lost: %+v", outcome)
}
}
func TestAFailedCreateIsNotInvited(t *testing.T) {
invites := &countingInviter{sent: true}
service := NewUserService(&stubUserRepo{err: errors.New("duplicate authname")}, invites)
if _, _, err := service.CreateUser(models.User{Email: "raj@rmart.example"}); err == nil {
t.Fatal("a failed create was reported as success")
}
if invites.calls != 0 {
t.Fatal("invited an account that was never created")
}
}
func TestStaffCreatedThroughTheTenantPathIsAlsoInvited(t *testing.T) {
// Two endpoints make back-office accounts — `users/create` and
// `tenants/createstaff` — and the console has used both. An invitation on one
// only would be a gap nobody could see from the screen they were using.
invites := &countingInviter{sent: true}
service := NewTenantService(&recordingTenantRepo{}, invites)
outcome, err := service.CreateStaff(models.User{
Email: "kavi@rmart.example", Tenantid: 1147,
})
if err != nil {
t.Fatalf("CreateStaff: %v", err)
}
if !outcome.Sent || invites.userid != 5150 {
t.Fatalf("not invited, or the wrong account: outcome=%+v userid=%d", outcome, invites.userid)
}
}
/* ── The login a branch spawns ───────────────────────────────────────────── */
type branchRepo struct {
repositories.TenantRepository
spawned int
err error
}
func (r *branchRepo) CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, int, error) {
if r.err != nil {
return models.Tenantlocations{}, 0, r.err
}
data.Locationid = 4420
return data, r.spawned, nil
}
func TestABranchesOwnLoginIsInvited(t *testing.T) {
invites := &countingInviter{sent: true}
service := NewTenantService(&branchRepo{spawned: 9001}, invites)
resp := service.CreateTenantLocation(models.Tenantlocations{
Locationname: "R Mart Peelamedu", Email: "peelamedu@rmart.example",
Tenantid: 1147, Address: "100 Feet Road", City: "Coimbatore",
})
if resp["status"] != true {
t.Fatalf("branch not created: %+v", resp)
}
if resp["invited"] != true {
t.Fatalf("the branch's own login was not invited: %+v", resp)
}
if invites.userid != 9001 {
t.Errorf("invited user %d, want the spawned login (9001)", invites.userid)
}
if invites.email != "peelamedu@rmart.example" {
t.Errorf("invitation addressed to %q, want the branch's email", invites.email)
}
}
func TestABranchHandedToAnExistingPersonSendsNothing(t *testing.T) {
// `spawned: 0` is the repository saying it created no account, because an
// `operatorid` was named. That person had a login before this branch existed,
// and re-inviting them would be a password reset in a branch's clothing —
// the one thing this whole mechanism is built to not become.
invites := &countingInviter{sent: true}
service := NewTenantService(&branchRepo{spawned: 0}, invites)
resp := service.CreateTenantLocation(models.Tenantlocations{
Locationname: "R Mart Gandhipuram", Operatorid: 7781, Tenantid: 1147,
})
if resp["status"] != true {
t.Fatalf("branch not created: %+v", resp)
}
if invites.calls != 0 {
t.Fatal("re-invited an existing person because a branch was commissioned")
}
if resp["invited"] != false {
t.Errorf("invited should be false when there was nobody to invite: %+v", resp)
}
if reason, _ := resp["invitereason"].(string); reason != "" {
// Nothing went wrong, so nothing is explained. A reason here reads as a
// failure on a screen that is reporting a success.
t.Errorf("apologised for an invitation that was never owed: %q", reason)
}
}
func TestAFailedBranchIsNotInvited(t *testing.T) {
invites := &countingInviter{sent: true}
service := NewTenantService(&branchRepo{err: errors.New("no such tenant")}, invites)
resp := service.CreateTenantLocation(models.Tenantlocations{
Locationname: "R Mart Nowhere", Email: "nowhere@rmart.example",
})
if resp["status"] != false {
t.Fatalf("a failed create was reported as success: %+v", resp)
}
if invites.calls != 0 {
t.Fatal("invited a login for a branch that does not exist")
}
}
/* ── Resending to one named person ───────────────────────────────────────── */
type userTargetRepo struct {
repositories.TenantRepository
target repositories.InviteTarget
err error
asked int
}
func (r *userTargetRepo) InviteTargetForUser(userID int) (repositories.InviteTarget, error) {
r.asked = userID
if r.err != nil {
return repositories.InviteTarget{}, r.err
}
return r.target, nil
}
func TestResendReachesAStaffMemberByUserid(t *testing.T) {
// The owner is reachable by tenantid because there is one of them. Everybody
// else has to be named, and before this there was no way to reach them at
// all — the only repair was editing the database.
repo := &userTargetRepo{target: repositories.InviteTarget{
Userid: 7781, Email: "meena@rmart.example", Tenantname: "R Mart",
}}
invites := &countingInviter{sent: true}
outcome, err := NewTenantService(repo, invites).ResendInviteToUser(7781)
if err != nil {
t.Fatalf("ResendInviteToUser: %v", err)
}
if !outcome.Sent || repo.asked != 7781 || invites.userid != 7781 {
t.Fatalf("wrong person: outcome=%+v asked=%d invited=%d", outcome, repo.asked, invites.userid)
}
}
func TestResendToAUserRefusesOneWhoAlreadyHasAPassword(t *testing.T) {
// Same line as the tenant resend draws, and it has to be drawn here too:
// this endpoint takes any userid, so without the check it would re-issue a
// working password link for every account on the platform.
repo := &userTargetRepo{target: repositories.InviteTarget{
Userid: 7781, Email: "meena@rmart.example", Tenantname: "R Mart", IsSetUp: true,
}}
invites := &countingInviter{sent: true}
_, err := NewTenantService(repo, invites).ResendInviteToUser(7781)
if err == nil {
t.Fatal("re-invited an account that already has a password")
}
if invites.calls != 0 {
t.Fatal("a link was minted for an account that is already set up")
}
if !strings.Contains(err.Error(), "sign-in") {
t.Errorf("the refusal does not say what to do instead: %v", err)
}
}
func TestResendToAUserWithNoBusinessStillReadsSensibly(t *testing.T) {
// A back-office account with no tenant is a Nearle staff row, and one exists.
// The refusal interpolates the business name, so an empty one would read
// " has already set a password".
repo := &userTargetRepo{target: repositories.InviteTarget{
Userid: 12, Email: "ops@nearle.in", Tenantname: "", IsSetUp: true,
}}
_, err := NewTenantService(repo, &countingInviter{}).ResendInviteToUser(12)
if err == nil {
t.Fatal("re-invited an account that already has a password")
}
if strings.HasPrefix(err.Error(), " ") || strings.Contains(err.Error(), " ") {
t.Errorf("the refusal has a hole where the business name should be: %q", err)
}
}
func TestResendToAUserPassesThroughALookupFailure(t *testing.T) {
repo := &userTargetRepo{err: errors.New("user 99 is not a back-office account on this platform")}
invites := &countingInviter{}
_, err := NewTenantService(repo, invites).ResendInviteToUser(99)
if err == nil || !strings.Contains(err.Error(), "back-office") {
t.Fatalf("the lookup's reason was lost: %v", err)
}
if invites.calls != 0 {
t.Fatal("a link was minted for an account that could not be found")
}
}