package usersignup import ( "testing" "time" "github.com/italypaleale/francis/actor" "github.com/italypaleale/francis/host/local" "github.com/stretchr/testify/require" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) // newSignupTokenActorService starts a test actor host with the signup token actor registered and returns its service func newSignupTokenActorService(t *testing.T) *actor.Service { t.Helper() var svc *actor.Service testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) { err := h.RegisterActor(SignupTokenActorType, NewSignupTokenActor) require.NoError(t, err) svc = h.Service() }) require.NotNil(t, svc) return svc } func createSignupTokenForTest(t *testing.T, svc *actor.Service, token string, state SignupTokenState) { t.Helper() _, err := svc.Invoke(t.Context(), SignupTokenActorType, token, SignupTokenMethodCreate, state) require.NoError(t, err) } func consumeSignupTokenForTest(t *testing.T, svc *actor.Service, token string) signupTokenConsumeResponse { t.Helper() res, err := svc.Invoke(t.Context(), SignupTokenActorType, token, signupTokenMethodConsume, nil) require.NoError(t, err) var out signupTokenConsumeResponse err = res.Decode(&out) require.NoError(t, err) return out } // listSignupTokenIDsForTest returns the actor IDs (that is, the token values) of every stored signup token func listSignupTokenIDsForTest(t *testing.T, svc *actor.Service) []string { t.Helper() res, err := svc.ListStates(t.Context(), SignupTokenActorType, nil) require.NoError(t, err) ids := make([]string, len(res.States)) for i, st := range res.States { ids[i] = st.ActorID } return ids } func TestSignupTokenActorConsume(t *testing.T) { svc := newSignupTokenActorService(t) createSignupTokenForTest(t, svc, "token-1", SignupTokenState{ ID: "id-1", ExpiresAt: time.Now().Add(time.Hour), UsageLimit: 1, UserGroupIDs: []string{"group-a", "group-b"}, CreatedAt: time.Now(), }) // First consume succeeds and returns the token's user groups res := consumeSignupTokenForTest(t, svc, "token-1") require.Equal(t, signupTokenConsumeOK, res.Status) require.Equal(t, []string{"group-a", "group-b"}, res.UserGroupIDs) // Second consume fails: the usage limit (1) has been reached res = consumeSignupTokenForTest(t, svc, "token-1") require.Equal(t, signupTokenConsumeLimitReached, res.Status) } func TestSignupTokenActorConsumeNotFound(t *testing.T) { svc := newSignupTokenActorService(t) res := consumeSignupTokenForTest(t, svc, "does-not-exist") require.Equal(t, signupTokenConsumeNotFound, res.Status) } // TestSignupTokenActorCreateExpired verifies that a token that has already expired is never stored, since its state TTL would be in the past func TestSignupTokenActorCreateExpired(t *testing.T) { svc := newSignupTokenActorService(t) createSignupTokenForTest(t, svc, "token-expired", SignupTokenState{ ID: "id-expired", ExpiresAt: time.Now().Add(-time.Minute), UsageLimit: 1, CreatedAt: time.Now().Add(-time.Hour), }) require.Empty(t, listSignupTokenIDsForTest(t, svc)) require.Equal(t, signupTokenConsumeNotFound, consumeSignupTokenForTest(t, svc, "token-expired").Status) } func TestSignupTokenActorRelease(t *testing.T) { svc := newSignupTokenActorService(t) createSignupTokenForTest(t, svc, "token-2", SignupTokenState{ ID: "id-2", ExpiresAt: time.Now().Add(time.Hour), UsageLimit: 2, CreatedAt: time.Now(), }) // Consume both uses require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-2").Status) require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-2").Status) require.Equal(t, signupTokenConsumeLimitReached, consumeSignupTokenForTest(t, svc, "token-2").Status) // Release one use (compensation) _, err := svc.Invoke(t.Context(), SignupTokenActorType, "token-2", signupTokenMethodRelease, nil) require.NoError(t, err) // Consuming succeeds again now that a use was released require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-2").Status) } func TestSignupTokenActorDelete(t *testing.T) { svc := newSignupTokenActorService(t) createSignupTokenForTest(t, svc, "token-3", SignupTokenState{ ID: "id-3", ExpiresAt: time.Now().Add(time.Hour), UsageLimit: 1, CreatedAt: time.Now(), }) require.Equal(t, []string{"token-3"}, listSignupTokenIDsForTest(t, svc)) _, err := svc.Invoke(t.Context(), SignupTokenActorType, "token-3", SignupTokenMethodDelete, nil) require.NoError(t, err) require.Empty(t, listSignupTokenIDsForTest(t, svc)) // The token can no longer be consumed require.Equal(t, signupTokenConsumeNotFound, consumeSignupTokenForTest(t, svc, "token-3").Status) // Deleting a token that no longer exists is a no-op _, err = svc.Invoke(t.Context(), SignupTokenActorType, "token-3", SignupTokenMethodDelete, nil) require.NoError(t, err) } // TestSignupTokenActorMigrateDoesNotOverwrite verifies that the one-time migration never resets a token that was already migrated and used since func TestSignupTokenActorMigrateDoesNotOverwrite(t *testing.T) { svc := newSignupTokenActorService(t) state := SignupTokenState{ ID: "id-4", ExpiresAt: time.Now().Add(time.Hour), UsageLimit: 2, CreatedAt: time.Now(), } createSignupTokenForTest(t, svc, "token-4", state) // Use the token once require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-4").Status) // Re-running the migration must not reset the usage count _, err := svc.Invoke(t.Context(), SignupTokenActorType, "token-4", signupTokenMethodMigrate, state) require.NoError(t, err) // Only one use is left, so a single consume succeeds and the next one doesn't require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-4").Status) require.Equal(t, signupTokenConsumeLimitReached, consumeSignupTokenForTest(t, svc, "token-4").Status) }