Add database functionality to manipulate the links table
This commit is contained in:
192
internal/database/links/links.go
Normal file
192
internal/database/links/links.go
Normal file
@ -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
|
||||
}
|
329
internal/database/links/links_test.go
Normal file
329
internal/database/links/links_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user