Files
owncast/activitypub/jobs/followervalidation_test.go
T
Gabe KangasandGitHub 5a4af3a294 ActivityPub background actor validation, update and cleanup process (#4765)
* feat(ap): add validation+update+cleanup AP actor job

* fix(test): use time.Time instead of sql.NullTime

* feat(test): add integration test for AP follower cleanup/validation job
2026-02-02 17:34:25 -08:00

352 lines
10 KiB
Go

package jobs
import (
"net/url"
"os"
"testing"
"time"
"github.com/go-fed/activity/streams"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils"
)
func TestMain(m *testing.M) {
setup()
code := m.Run()
os.Exit(code)
}
var datastore *data.Datastore
func setup() {
resetTestDatabase()
}
// resetTestDatabase initializes a fresh in-memory database for testing.
func resetTestDatabase() {
data.SetupPersistence(":memory:")
datastore = data.GetDatastore()
persistence.Setup(datastore)
}
// setupTestWithRepo resets the database and returns a new repository instance.
func setupTestWithRepo(t *testing.T) followersrepository.FollowersRepository {
t.Helper()
resetTestDatabase()
return followersrepository.New(datastore)
}
func createTestFollower(repo followersrepository.FollowersRepository, iri, inbox, name, username string) {
actorIRI, _ := url.Parse(iri)
inboxURL, _ := url.Parse(inbox)
requestIRI, _ := url.Parse("https://fake.server/follow/request")
fakeRequest := streams.NewActivityStreamsFollow()
repo.Add(apmodels.ActivityPubActor{
ActorIri: actorIRI,
Inbox: inboxURL,
Name: name,
Username: username,
FullUsername: username + "@fake.server",
FollowRequestIri: requestIRI,
RequestObject: fakeRequest,
}, true)
}
func TestGetFollowersToValidate(t *testing.T) {
repo := setupTestWithRepo(t)
// Create some test followers
for i := 0; i < 10; i++ {
user, _ := utils.GenerateRandomString(10)
createTestFollower(repo, "https://fake.server/user/"+user, "https://fake.server/user/"+user+"/inbox", user, user)
}
// Get followers to validate
followers, err := repo.GetFollowersToValidate(5)
if err != nil {
t.Fatalf("Error getting followers to validate: %s", err)
}
if len(followers) != 5 {
t.Errorf("Expected 5 followers to validate, got %d", len(followers))
}
}
func TestUpdateFollowerValidationSuccess(t *testing.T) {
repo := setupTestWithRepo(t)
// Create a test follower
testIRI := "https://fake.server/user/testuser"
createTestFollower(repo, testIRI, "https://fake.server/user/testuser/inbox", "Test User", "testuser")
// Mark validation as successful
err := repo.UpdateFollowerValidationSuccess(testIRI)
if err != nil {
t.Fatalf("Error updating follower validation success: %s", err)
}
// Verify the follower was updated
followers, err := repo.GetFollowersToValidate(10)
if err != nil {
t.Fatalf("Error getting followers: %s", err)
}
// After validation success, FirstValidationFailureAt should be NULL/invalid
for _, f := range followers {
if f.ActorIRI == testIRI {
if f.FirstValidationFailureAt.Valid {
t.Error("Expected FirstValidationFailureAt to be NULL after success")
}
}
}
}
func TestUpdateFollowerValidationFailure(t *testing.T) {
repo := setupTestWithRepo(t)
// Create a test follower
testIRI := "https://fake.server/user/testuser"
createTestFollower(repo, testIRI, "https://fake.server/user/testuser/inbox", "Test User", "testuser")
// Mark validation as failed
err := repo.UpdateFollowerValidationFailure(testIRI)
if err != nil {
t.Fatalf("Error updating follower validation failure: %s", err)
}
// Verify the FirstValidationFailureAt is set
followers, err := repo.GetFollowersToValidate(10)
if err != nil {
t.Fatalf("Error getting followers: %s", err)
}
found := false
for _, f := range followers {
if f.ActorIRI == testIRI {
found = true
if !f.FirstValidationFailureAt.Valid {
t.Error("Expected FirstValidationFailureAt to be set after failure")
}
}
}
if !found {
t.Error("Test follower not found in results")
}
}
func TestValidationFailureClearedOnSuccess(t *testing.T) {
repo := setupTestWithRepo(t)
// Create a test follower
testIRI := "https://fake.server/user/testuser"
createTestFollower(repo, testIRI, "https://fake.server/user/testuser/inbox", "Test User", "testuser")
// Mark validation as failed first
err := repo.UpdateFollowerValidationFailure(testIRI)
if err != nil {
t.Fatalf("Error updating follower validation failure: %s", err)
}
// Then mark as successful
err = repo.UpdateFollowerValidationSuccess(testIRI)
if err != nil {
t.Fatalf("Error updating follower validation success: %s", err)
}
// Verify FirstValidationFailureAt is cleared
followers, err := repo.GetFollowersToValidate(10)
if err != nil {
t.Fatalf("Error getting followers: %s", err)
}
for _, f := range followers {
if f.ActorIRI == testIRI {
if f.FirstValidationFailureAt.Valid {
t.Error("Expected FirstValidationFailureAt to be NULL after success")
}
}
}
}
func TestRemoveByIRI(t *testing.T) {
repo := setupTestWithRepo(t)
// Create a test follower
testIRI := "https://fake.server/user/testuser"
createTestFollower(repo, testIRI, "https://fake.server/user/testuser/inbox", "Test User", "testuser")
// Verify follower exists
count, err := repo.GetCount()
if err != nil {
t.Fatalf("Error getting count: %s", err)
}
if count != 1 {
t.Errorf("Expected 1 follower, got %d", count)
}
// Remove the follower
err = repo.RemoveByIRI(testIRI)
if err != nil {
t.Fatalf("Error removing follower: %s", err)
}
// Verify follower is removed
count, err = repo.GetCount()
if err != nil {
t.Fatalf("Error getting count: %s", err)
}
if count != 0 {
t.Errorf("Expected 0 followers after removal, got %d", count)
}
}
func TestFailureDurationThresholdLogic(t *testing.T) {
repo := setupTestWithRepo(t)
// Create a test follower
testIRI := "https://fake.server/user/testfailure"
createTestFollower(repo, testIRI, "https://fake.server/user/testfailure/inbox", "Test User", "testfailure")
// Set first_validation_failure_at to 8 days ago (past threshold)
eightDaysAgo := time.Now().Add(-8 * 24 * time.Hour)
_, err := datastore.DB.Exec(
"UPDATE ap_followers SET first_validation_failure_at = ? WHERE iri = ?",
eightDaysAgo, testIRI,
)
if err != nil {
t.Fatalf("Error setting first_validation_failure_at: %s", err)
}
// Fetch the follower
followers, err := repo.GetFollowersToValidate(1)
if err != nil {
t.Fatalf("Error getting followers: %s", err)
}
if len(followers) != 1 {
t.Fatalf("Expected 1 follower, got %d", len(followers))
}
// Verify the failure duration is calculated correctly
failureDuration := time.Since(followers[0].FirstValidationFailureAt.Time)
if failureDuration < FailureDurationThreshold {
t.Errorf("Expected failure duration (%v) to be >= threshold (%v)", failureDuration, FailureDurationThreshold)
}
// Test removal - this directly tests the removal logic without network calls
err = repo.RemoveByIRI(testIRI)
if err != nil {
t.Fatalf("Error removing follower: %s", err)
}
// Verify the follower was removed
count, err := repo.GetCount()
if err != nil {
t.Fatalf("Error getting count: %s", err)
}
if count != 0 {
t.Errorf("Expected follower to be removed, but count is %d", count)
}
}
func TestFailureNotRemovedBeforeThreshold(t *testing.T) {
repo := setupTestWithRepo(t)
// Create a test follower
testIRI := "https://fake.server/user/testnotremoved"
createTestFollower(repo, testIRI, "https://fake.server/user/testnotremoved/inbox", "Test User", "testnotremoved")
// Set first_validation_failure_at to 1 day ago (not past threshold)
oneDayAgo := time.Now().Add(-1 * 24 * time.Hour)
_, err := datastore.DB.Exec(
"UPDATE ap_followers SET first_validation_failure_at = ? WHERE iri = ?",
oneDayAgo, testIRI,
)
if err != nil {
t.Fatalf("Error setting first_validation_failure_at: %s", err)
}
// Fetch the follower
followers, err := repo.GetFollowersToValidate(1)
if err != nil {
t.Fatalf("Error getting followers: %s", err)
}
if len(followers) != 1 {
t.Fatalf("Expected 1 follower, got %d", len(followers))
}
// Verify the failure duration is below threshold
failureDuration := time.Since(followers[0].FirstValidationFailureAt.Time)
if failureDuration >= FailureDurationThreshold {
t.Errorf("Expected failure duration (%v) to be < threshold (%v)", failureDuration, FailureDurationThreshold)
}
// Follower should NOT be removed yet
count, err := repo.GetCount()
if err != nil {
t.Fatalf("Error getting count: %s", err)
}
if count != 1 {
t.Errorf("Expected follower to NOT be removed yet, but count is %d", count)
}
}
func TestFollowersOrderedByOldestValidatedFirst(t *testing.T) {
repo := setupTestWithRepo(t)
// Create followers
iri1 := "https://fake.server/user/first"
iri2 := "https://fake.server/user/second"
iri3 := "https://fake.server/user/third"
createTestFollower(repo, iri1, "https://fake.server/user/first/inbox", "First", "first")
createTestFollower(repo, iri2, "https://fake.server/user/second/inbox", "Second", "second")
createTestFollower(repo, iri3, "https://fake.server/user/third/inbox", "Third", "third")
// Set different last_validated_at times
now := time.Now()
_, err := datastore.DB.Exec("UPDATE ap_followers SET last_validated_at = ? WHERE iri = ?",
now.Add(-3*time.Hour), iri1)
if err != nil {
t.Fatalf("Error updating: %s", err)
}
_, err = datastore.DB.Exec("UPDATE ap_followers SET last_validated_at = ? WHERE iri = ?",
now.Add(-1*time.Hour), iri2)
if err != nil {
t.Fatalf("Error updating: %s", err)
}
// iri3 has NULL last_validated_at (never validated)
// Get followers - should return in order: iri3 (NULL), iri1 (oldest), iri2 (newest)
followers, err := repo.GetFollowersToValidate(3)
if err != nil {
t.Fatalf("Error getting followers: %s", err)
}
if len(followers) != 3 {
t.Fatalf("Expected 3 followers, got %d", len(followers))
}
// NULL values should come first (NULLS FIRST in query)
if followers[0].ActorIRI != iri3 {
t.Errorf("Expected first follower to be %s (never validated), got %s", iri3, followers[0].ActorIRI)
}
// Then oldest validated
if followers[1].ActorIRI != iri1 {
t.Errorf("Expected second follower to be %s (oldest validated), got %s", iri1, followers[1].ActorIRI)
}
// Then newest validated
if followers[2].ActorIRI != iri2 {
t.Errorf("Expected third follower to be %s (newest validated), got %s", iri2, followers[2].ActorIRI)
}
}