diff --git a/internal/database/links/links.go b/internal/database/links/links.go new file mode 100644 index 0000000..519eaeb --- /dev/null +++ b/internal/database/links/links.go @@ -0,0 +1,192 @@ +package links + +import ( + "database/sql" + "time" + + "git.omicron.one/omicron/linkshare/internal/database" + . "git.omicron.one/omicron/linkshare/internal/util/option" +) + +// Link represents a stored link +type Link struct { + ID int64 + URL string + Title string + CreatedAt time.Time + UpdatedAt Option[time.Time] + IsPrivate bool +} + +// Repository handles link storage operations +type Repository struct { + db *database.DB +} + +// NewRepository creates a new link repository +func NewRepository(db *database.DB) *Repository { + return &Repository{db: db} +} + +// Create adds a new link to the database +func (r *Repository) Create(url, title string, isPrivate bool) (int64, error) { + var id int64 + err := r.db.Transaction(func(tx *sql.Tx) error { + now := time.Now().UTC().Format(time.RFC3339) + result, err := tx.Exec( + "INSERT INTO links (url, title, created_at, is_private) VALUES (?, ?, ?, ?)", + url, title, now, isPrivate, + ) + if err != nil { + return err + } + id, err = result.LastInsertId() + return err + }) + return id, err +} + +// Get retrieves a single link by ID +func (r *Repository) Get(id int64) (*Link, error) { + var ( + link Link + createdAt string + updatedAt sql.NullString + ) + + err := r.db.Transaction(func(tx *sql.Tx) error { + row := tx.QueryRow( + "SELECT id, url, title, created_at, updated_at, is_private FROM links WHERE id = ?", + id, + ) + + err := row.Scan(&link.ID, &link.URL, &link.Title, &createdAt, &updatedAt, &link.IsPrivate) + if err != nil { + return err + } + + created, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + return err + } + link.CreatedAt = created + + if updatedAt.Valid { + updated, err := time.Parse(time.RFC3339, updatedAt.String) + if err != nil { + return err + } + link.UpdatedAt = Some(updated) + } else { + link.UpdatedAt = None[time.Time]() + } + + return nil + }) + if err != nil { + return nil, err + } + return &link, nil +} + +// Update updates an existing link's fields +func (r *Repository) Update(id int64, url, title string, isPrivate bool) error { + return r.db.Transaction(func(tx *sql.Tx) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := tx.Exec( + "UPDATE links SET url = ?, title = ?, updated_at = ?, is_private = ? WHERE id = ?", + url, title, now, isPrivate, id, + ) + return err + }) +} + +// Delete removes a link from the database +func (r *Repository) Delete(id int64) error { + return r.db.Transaction(func(tx *sql.Tx) error { + _, err := tx.Exec("DELETE FROM links WHERE id = ?", id) + return err + }) +} + +// List returns a paginated list of links +func (r *Repository) List(includePrivate bool, offset, limit int) ([]*Link, error) { + var links []*Link + + err := r.db.Transaction(func(tx *sql.Tx) error { + var rows *sql.Rows + var err error + + if includePrivate { + rows, err = tx.Query( + `SELECT id, url, title, created_at, updated_at, is_private + FROM links ORDER BY created_at DESC LIMIT ? OFFSET ?`, + limit, offset, + ) + } else { + rows, err = tx.Query( + `SELECT id, url, title, created_at, updated_at, is_private + FROM links WHERE is_private = 0 ORDER BY created_at DESC LIMIT ? OFFSET ?`, + limit, offset, + ) + } + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var ( + link Link + createdAt string + updatedAt sql.NullString + ) + + err := rows.Scan(&link.ID, &link.URL, &link.Title, &createdAt, &updatedAt, &link.IsPrivate) + if err != nil { + return err + } + + created, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + return err + } + link.CreatedAt = created + + if updatedAt.Valid { + updated, err := time.Parse(time.RFC3339, updatedAt.String) + if err != nil { + return err + } + link.UpdatedAt = Some(updated) + } else { + link.UpdatedAt = None[time.Time]() + } + + links = append(links, &link) + } + + return rows.Err() + }) + if err != nil { + return nil, err + } + return links, nil +} + +// Count returns the total number of links in the database +func (r *Repository) Count(includePrivate bool) (int, error) { + var count int + + err := r.db.Transaction(func(tx *sql.Tx) error { + var row *sql.Row + if includePrivate { + row = tx.QueryRow("SELECT COUNT(*) FROM links") + } else { + row = tx.QueryRow("SELECT COUNT(*) FROM links WHERE is_private") + } + return row.Scan(&count) + }) + + return count, err +} diff --git a/internal/database/links/links_test.go b/internal/database/links/links_test.go new file mode 100644 index 0000000..8f64021 --- /dev/null +++ b/internal/database/links/links_test.go @@ -0,0 +1,329 @@ +package links_test + +import ( + "os" + "testing" + "time" + + "git.omicron.one/omicron/linkshare/internal/database" + "git.omicron.one/omicron/linkshare/internal/database/links" +) + +func setupTestDB(t *testing.T) (*database.DB, string) { + t.Helper() + + cwd, err := os.Getwd() + t.Logf("Current working directory: %s", cwd) + + // Create temp file for database + tempFile, err := os.CreateTemp("", "linkshare-links-test-*.db") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + tempFile.Close() + + dbPath := tempFile.Name() + + // Open database + db, err := database.Open(dbPath) + if err != nil { + os.Remove(dbPath) + t.Fatalf("Failed to open database: %v", err) + } + + // Initialize database with schema + err = db.Initialize("../../../schema") + if err != nil { + db.Close() + os.Remove(dbPath) + t.Fatalf("Failed to initialize database: %v", err) + } + + return db, dbPath +} + +func TestRepository_Create(t *testing.T) { + db, dbPath := setupTestDB(t) + defer func() { + db.Close() + os.Remove(dbPath) + }() + + repo := links.NewRepository(db) + + // Test creating a link + id, err := repo.Create("https://example.com", "Example", false) + if err != nil { + t.Fatalf("Failed to create link: %v", err) + } + if id <= 0 { + t.Fatalf("Expected positive ID, got %d", id) + } + + // Verify link was created by retrieving it + link, err := repo.Get(id) + if err != nil { + t.Fatalf("Failed to get link: %v", err) + } + + if link.URL != "https://example.com" { + t.Errorf("Expected URL 'https://example.com', got '%s'", link.URL) + } +} + +func TestRepository_Get(t *testing.T) { + db, dbPath := setupTestDB(t) + defer func() { + db.Close() + os.Remove(dbPath) + }() + + repo := links.NewRepository(db) + + // Insert test data + id, err := repo.Create("https://example.com", "Example", true) + if err != nil { + t.Fatalf("Failed to create link: %v", err) + } + + // Test getting a link + link, err := repo.Get(id) + if err != nil { + t.Fatalf("Failed to get link: %v", err) + } + + if link.ID != id { + t.Errorf("Expected ID %d, got %d", id, link.ID) + } + if link.URL != "https://example.com" { + t.Errorf("Expected URL 'https://example.com', got '%s'", link.URL) + } + if link.Title != "Example" { + t.Errorf("Expected Title 'Example', got '%s'", link.Title) + } + if link.IsPrivate != true { + t.Errorf("Expected IsPrivate true, got %v", link.IsPrivate) + } + if link.UpdatedAt.IsSome() { + t.Errorf("Expected UpdatedAt to be None, got %v", link.UpdatedAt) + } + + // Test getting non-existent link + _, err = repo.Get(id + 1) + if err == nil { + t.Fatal("Expected error when getting non-existent link") + } +} + +func TestRepository_Update(t *testing.T) { + db, dbPath := setupTestDB(t) + defer func() { + db.Close() + os.Remove(dbPath) + }() + + repo := links.NewRepository(db) + + // Insert test data + id, err := repo.Create("https://example.com", "Example", false) + if err != nil { + t.Fatalf("Failed to create link: %v", err) + } + + // Test updating a link + err = repo.Update(id, "https://updated.com", "Updated", true) + if err != nil { + t.Fatalf("Failed to update link: %v", err) + } + + // Verify link was updated + link, err := repo.Get(id) + if err != nil { + t.Fatalf("Failed to get link: %v", err) + } + + if link.URL != "https://updated.com" { + t.Errorf("Expected URL 'https://updated.com', got '%s'", link.URL) + } + if link.Title != "Updated" { + t.Errorf("Expected Title 'Updated', got '%s'", link.Title) + } + if link.IsPrivate != true { + t.Errorf("Expected IsPrivate true, got %v", link.IsPrivate) + } + if !link.UpdatedAt.IsSome() { + t.Error("Expected UpdatedAt to be set") + } +} + +func TestRepository_Delete(t *testing.T) { + db, dbPath := setupTestDB(t) + defer func() { + db.Close() + os.Remove(dbPath) + }() + + repo := links.NewRepository(db) + + // Insert test data + id, err := repo.Create("https://example.com", "Example", false) + if err != nil { + t.Fatalf("Failed to create link: %v", err) + } + + // Test deleting a link + err = repo.Delete(id) + if err != nil { + t.Fatalf("Failed to delete link: %v", err) + } + + // Verify link was deleted + _, err = repo.Get(id) + if err == nil { + t.Fatal("Expected error after deletion") + } +} + +func TestRepository_List(t *testing.T) { + db, dbPath := setupTestDB(t) + defer func() { + db.Close() + os.Remove(dbPath) + }() + + repo := links.NewRepository(db) + + // Insert test data + urls := []struct { + url string + isPrivate bool + }{ + {"https://example1.com", true}, + {"https://example2.com", false}, + {"https://example3.com", false}, + {"https://example4.com", true}, + {"https://example5.com", false}, + } + + for i, info := range urls { + _, err := repo.Create(info.url, "Example "+string(rune('A'+i)), info.isPrivate) + if err != nil { + t.Fatalf("Failed to create link: %v", err) + } + + // Add a small delay to ensure different created_at times + time.Sleep(10 * time.Millisecond) + } + + // Test full listing with pagination + links, err := repo.List(true, 0, 3) + if err != nil { + t.Fatalf("Failed to list links: %v", err) + } + + if len(links) != 3 { + t.Fatalf("Expected 3 links, got %d", len(links)) + } + + // Check order (newest first) + for i := 0; i < len(links)-1; i++ { + if links[i].CreatedAt.Before(links[i+1].CreatedAt) { + t.Errorf("Links not in correct order") + } + } + + // Test second page of full listing + links, err = repo.List(true, 3, 2) + if err != nil { + t.Fatalf("Failed to list links: %v", err) + } + + if len(links) != 2 { + t.Fatalf("Expected 2 links, got %d", len(links)) + } + + // Test public listing + links, err = repo.List(false, 0, 3) + if err != nil { + t.Fatalf("Failed to list links: %v", err) + } + + if len(links) != 3 { + t.Fatalf("Expected 3 links, got %d", len(links)) + } + + for _, link := range links { + if link.IsPrivate { + t.Fatalf("private link in public listing %v", link) + } + } + + // Try to get more public links + links, err = repo.List(false, 3, 3) + if err != nil { + t.Fatalf("Failed to list links: %v", err) + } + if len(links) != 0 { + t.Fatalf("Expected 0 links, got %d", len(links)) + } +} + +func TestRepository_Count(t *testing.T) { + db, dbPath := setupTestDB(t) + defer func() { + db.Close() + os.Remove(dbPath) + }() + + repo := links.NewRepository(db) + + // Check full count with empty table + count, err := repo.Count(true) + if err != nil { + t.Fatalf("Failed to count links: %v", err) + } + if count != 0 { + t.Fatalf("Expected 0 links, got %d", count) + } + + // Check public count with empty table + count, err = repo.Count(false) + if err != nil { + t.Fatalf("Failed to count links: %v", err) + } + if count != 0 { + t.Fatalf("Expected 0 links, got %d", count) + } + + // Insert test data + numLinks := 5 + for i := 0; i < numLinks; i++ { + _, err := repo.Create( + "https://example"+string(rune('1'+i))+".com", + "Example "+string(rune('A'+i)), + i%2 == 1, + ) + if err != nil { + t.Fatalf("Failed to create link: %v", err) + } + } + pubLinks := numLinks / 2 + + // Check full count again + count, err = repo.Count(true) + if err != nil { + t.Fatalf("Failed to count links: %v", err) + } + if count != numLinks { + t.Fatalf("Expected %d links, got %d", numLinks, count) + } + + // Check public count again + count, err = repo.Count(false) + if err != nil { + t.Fatalf("Failed to count links: %v", err) + } + if count != pubLinks { + t.Fatalf("Expected %d links, got %d", pubLinks, count) + } +}