mirror of
https://github.com/btouchard/ackify-ce.git
synced 2026-02-28 10:48:47 -06:00
fix: Add missing files
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Document represents document metadata for tracking and integrity verification
|
||||
type Document struct {
|
||||
DocID string `json:"doc_id" db:"doc_id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
URL string `json:"url" db:"url"`
|
||||
Checksum string `json:"checksum" db:"checksum"`
|
||||
ChecksumAlgorithm string `json:"checksum_algorithm" db:"checksum_algorithm"`
|
||||
Description string `json:"description" db:"description"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
}
|
||||
|
||||
// DocumentInput represents the input for creating/updating document metadata
|
||||
type DocumentInput struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Checksum string `json:"checksum"`
|
||||
ChecksumAlgorithm string `json:"checksum_algorithm"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/btouchard/ackify-ce/internal/domain/models"
|
||||
"github.com/btouchard/ackify-ce/pkg/logger"
|
||||
)
|
||||
|
||||
// DocumentRepository handles document metadata persistence
|
||||
type DocumentRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewDocumentRepository creates a new DocumentRepository
|
||||
func NewDocumentRepository(db *sql.DB) *DocumentRepository {
|
||||
return &DocumentRepository{db: db}
|
||||
}
|
||||
|
||||
// Create creates a new document metadata entry
|
||||
func (r *DocumentRepository) Create(ctx context.Context, docID string, input models.DocumentInput, createdBy string) (*models.Document, error) {
|
||||
query := `
|
||||
INSERT INTO documents (doc_id, title, url, checksum, checksum_algorithm, description, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by
|
||||
`
|
||||
|
||||
doc := &models.Document{}
|
||||
err := r.db.QueryRowContext(
|
||||
ctx,
|
||||
query,
|
||||
docID,
|
||||
input.Title,
|
||||
input.URL,
|
||||
input.Checksum,
|
||||
input.ChecksumAlgorithm,
|
||||
input.Description,
|
||||
createdBy,
|
||||
).Scan(
|
||||
&doc.DocID,
|
||||
&doc.Title,
|
||||
&doc.URL,
|
||||
&doc.Checksum,
|
||||
&doc.ChecksumAlgorithm,
|
||||
&doc.Description,
|
||||
&doc.CreatedAt,
|
||||
&doc.UpdatedAt,
|
||||
&doc.CreatedBy,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to create document", "error", err.Error(), "doc_id", docID)
|
||||
return nil, fmt.Errorf("failed to create document: %w", err)
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// GetByDocID retrieves document metadata by document ID
|
||||
func (r *DocumentRepository) GetByDocID(ctx context.Context, docID string) (*models.Document, error) {
|
||||
query := `
|
||||
SELECT doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by
|
||||
FROM documents
|
||||
WHERE doc_id = $1
|
||||
`
|
||||
|
||||
doc := &models.Document{}
|
||||
err := r.db.QueryRowContext(ctx, query, docID).Scan(
|
||||
&doc.DocID,
|
||||
&doc.Title,
|
||||
&doc.URL,
|
||||
&doc.Checksum,
|
||||
&doc.ChecksumAlgorithm,
|
||||
&doc.Description,
|
||||
&doc.CreatedAt,
|
||||
&doc.UpdatedAt,
|
||||
&doc.CreatedBy,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to get document", "error", err.Error(), "doc_id", docID)
|
||||
return nil, fmt.Errorf("failed to get document: %w", err)
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// Update updates document metadata
|
||||
func (r *DocumentRepository) Update(ctx context.Context, docID string, input models.DocumentInput) (*models.Document, error) {
|
||||
query := `
|
||||
UPDATE documents
|
||||
SET title = $2, url = $3, checksum = $4, checksum_algorithm = $5, description = $6
|
||||
WHERE doc_id = $1
|
||||
RETURNING doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by
|
||||
`
|
||||
|
||||
doc := &models.Document{}
|
||||
err := r.db.QueryRowContext(
|
||||
ctx,
|
||||
query,
|
||||
docID,
|
||||
input.Title,
|
||||
input.URL,
|
||||
input.Checksum,
|
||||
input.ChecksumAlgorithm,
|
||||
input.Description,
|
||||
).Scan(
|
||||
&doc.DocID,
|
||||
&doc.Title,
|
||||
&doc.URL,
|
||||
&doc.Checksum,
|
||||
&doc.ChecksumAlgorithm,
|
||||
&doc.Description,
|
||||
&doc.CreatedAt,
|
||||
&doc.UpdatedAt,
|
||||
&doc.CreatedBy,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("document not found")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to update document", "error", err.Error(), "doc_id", docID)
|
||||
return nil, fmt.Errorf("failed to update document: %w", err)
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdate creates or updates document metadata
|
||||
func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, input models.DocumentInput, createdBy string) (*models.Document, error) {
|
||||
query := `
|
||||
INSERT INTO documents (doc_id, title, url, checksum, checksum_algorithm, description, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (doc_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
url = EXCLUDED.url,
|
||||
checksum = EXCLUDED.checksum,
|
||||
checksum_algorithm = EXCLUDED.checksum_algorithm,
|
||||
description = EXCLUDED.description
|
||||
RETURNING doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by
|
||||
`
|
||||
|
||||
doc := &models.Document{}
|
||||
err := r.db.QueryRowContext(
|
||||
ctx,
|
||||
query,
|
||||
docID,
|
||||
input.Title,
|
||||
input.URL,
|
||||
input.Checksum,
|
||||
input.ChecksumAlgorithm,
|
||||
input.Description,
|
||||
createdBy,
|
||||
).Scan(
|
||||
&doc.DocID,
|
||||
&doc.Title,
|
||||
&doc.URL,
|
||||
&doc.Checksum,
|
||||
&doc.ChecksumAlgorithm,
|
||||
&doc.Description,
|
||||
&doc.CreatedAt,
|
||||
&doc.UpdatedAt,
|
||||
&doc.CreatedBy,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to create or update document", "error", err.Error(), "doc_id", docID)
|
||||
return nil, fmt.Errorf("failed to create or update document: %w", err)
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// Delete deletes document metadata
|
||||
func (r *DocumentRepository) Delete(ctx context.Context, docID string) error {
|
||||
query := `DELETE FROM documents WHERE doc_id = $1`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, docID)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to delete document", "error", err.Error(), "doc_id", docID)
|
||||
return fmt.Errorf("failed to delete document: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("document not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List retrieves all documents with pagination
|
||||
func (r *DocumentRepository) List(ctx context.Context, limit, offset int) ([]*models.Document, error) {
|
||||
query := `
|
||||
SELECT doc_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by
|
||||
FROM documents
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, limit, offset)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to list documents", "error", err.Error())
|
||||
return nil, fmt.Errorf("failed to list documents: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
documents := []*models.Document{}
|
||||
for rows.Next() {
|
||||
doc := &models.Document{}
|
||||
err := rows.Scan(
|
||||
&doc.DocID,
|
||||
&doc.Title,
|
||||
&doc.URL,
|
||||
&doc.Checksum,
|
||||
&doc.ChecksumAlgorithm,
|
||||
&doc.Description,
|
||||
&doc.CreatedAt,
|
||||
&doc.UpdatedAt,
|
||||
&doc.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to scan document row", "error", err.Error())
|
||||
return nil, fmt.Errorf("failed to scan document: %w", err)
|
||||
}
|
||||
documents = append(documents, doc)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating documents: %w", err)
|
||||
}
|
||||
|
||||
return documents, nil
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//go:build integration
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/btouchard/ackify-ce/internal/domain/models"
|
||||
)
|
||||
|
||||
func setupDocumentsTable(t *testing.T, testDB *TestDB) {
|
||||
t.Helper()
|
||||
|
||||
schema := `
|
||||
DROP TABLE IF EXISTS documents;
|
||||
|
||||
CREATE TABLE documents (
|
||||
doc_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
checksum TEXT NOT NULL DEFAULT '',
|
||||
checksum_algorithm TEXT NOT NULL DEFAULT 'SHA-256' CHECK (checksum_algorithm IN ('SHA-256', 'SHA-512', 'MD5')),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX idx_documents_created_at ON documents(created_at DESC);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_documents_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_documents_updated_at
|
||||
BEFORE UPDATE ON documents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_documents_updated_at();
|
||||
`
|
||||
|
||||
_, err := testDB.DB.Exec(schema)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to setup documents table: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func clearDocumentsTable(t *testing.T, testDB *TestDB) {
|
||||
t.Helper()
|
||||
_, err := testDB.DB.Exec("TRUNCATE TABLE documents RESTART IDENTITY CASCADE")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to clear documents table: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentRepository_Create(t *testing.T) {
|
||||
testDB := SetupTestDB(t)
|
||||
setupDocumentsTable(t, testDB)
|
||||
|
||||
ctx := context.Background()
|
||||
repo := NewDocumentRepository(testDB.DB)
|
||||
|
||||
input := models.DocumentInput{
|
||||
Title: "Test Document",
|
||||
URL: "https://example.com/doc.pdf",
|
||||
Checksum: "abc123def456",
|
||||
ChecksumAlgorithm: "SHA-256",
|
||||
Description: "A test document for integration testing",
|
||||
}
|
||||
|
||||
doc, err := repo.Create(ctx, "test-doc-001", input, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create document: %v", err)
|
||||
}
|
||||
|
||||
if doc.DocID != "test-doc-001" {
|
||||
t.Errorf("Expected DocID test-doc-001, got %s", doc.DocID)
|
||||
}
|
||||
|
||||
if doc.Title != input.Title {
|
||||
t.Errorf("Expected Title %s, got %s", input.Title, doc.Title)
|
||||
}
|
||||
|
||||
if doc.URL != input.URL {
|
||||
t.Errorf("Expected URL %s, got %s", input.URL, doc.URL)
|
||||
}
|
||||
|
||||
if doc.Checksum != input.Checksum {
|
||||
t.Errorf("Expected Checksum %s, got %s", input.Checksum, doc.Checksum)
|
||||
}
|
||||
|
||||
if doc.ChecksumAlgorithm != input.ChecksumAlgorithm {
|
||||
t.Errorf("Expected ChecksumAlgorithm %s, got %s", input.ChecksumAlgorithm, doc.ChecksumAlgorithm)
|
||||
}
|
||||
|
||||
if doc.Description != input.Description {
|
||||
t.Errorf("Expected Description %s, got %s", input.Description, doc.Description)
|
||||
}
|
||||
|
||||
if doc.CreatedBy != "admin@example.com" {
|
||||
t.Errorf("Expected CreatedBy admin@example.com, got %s", doc.CreatedBy)
|
||||
}
|
||||
|
||||
if doc.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt should not be zero")
|
||||
}
|
||||
|
||||
if doc.UpdatedAt.IsZero() {
|
||||
t.Error("UpdatedAt should not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentRepository_GetByDocID(t *testing.T) {
|
||||
testDB := SetupTestDB(t)
|
||||
setupDocumentsTable(t, testDB)
|
||||
|
||||
ctx := context.Background()
|
||||
repo := NewDocumentRepository(testDB.DB)
|
||||
|
||||
input := models.DocumentInput{
|
||||
Title: "Get Test Document",
|
||||
URL: "https://example.com/get-doc.pdf",
|
||||
Checksum: "get123abc456",
|
||||
ChecksumAlgorithm: "SHA-512",
|
||||
Description: "Document for get testing",
|
||||
}
|
||||
|
||||
created, err := repo.Create(ctx, "get-doc-001", input, "user@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create document: %v", err)
|
||||
}
|
||||
|
||||
retrieved, err := repo.GetByDocID(ctx, "get-doc-001")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get document: %v", err)
|
||||
}
|
||||
|
||||
if retrieved == nil {
|
||||
t.Fatal("Retrieved document is nil")
|
||||
}
|
||||
|
||||
if retrieved.DocID != created.DocID {
|
||||
t.Errorf("Expected DocID %s, got %s", created.DocID, retrieved.DocID)
|
||||
}
|
||||
|
||||
if retrieved.Title != created.Title {
|
||||
t.Errorf("Expected Title %s, got %s", created.Title, retrieved.Title)
|
||||
}
|
||||
|
||||
// Test non-existent document
|
||||
nonExistent, err := repo.GetByDocID(ctx, "non-existent-doc")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for non-existent document, got %v", err)
|
||||
}
|
||||
|
||||
if nonExistent != nil {
|
||||
t.Error("Expected nil for non-existent document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentRepository_Update(t *testing.T) {
|
||||
testDB := SetupTestDB(t)
|
||||
setupDocumentsTable(t, testDB)
|
||||
|
||||
ctx := context.Background()
|
||||
repo := NewDocumentRepository(testDB.DB)
|
||||
|
||||
input := models.DocumentInput{
|
||||
Title: "Original Title",
|
||||
URL: "https://example.com/original.pdf",
|
||||
Checksum: "original123",
|
||||
ChecksumAlgorithm: "MD5",
|
||||
Description: "Original description",
|
||||
}
|
||||
|
||||
created, err := repo.Create(ctx, "update-doc-001", input, "creator@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create document: %v", err)
|
||||
}
|
||||
|
||||
updateInput := models.DocumentInput{
|
||||
Title: "Updated Title",
|
||||
URL: "https://example.com/updated.pdf",
|
||||
Checksum: "updated456",
|
||||
ChecksumAlgorithm: "SHA-256",
|
||||
Description: "Updated description",
|
||||
}
|
||||
|
||||
updated, err := repo.Update(ctx, "update-doc-001", updateInput)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update document: %v", err)
|
||||
}
|
||||
|
||||
if updated.Title != updateInput.Title {
|
||||
t.Errorf("Expected Title %s, got %s", updateInput.Title, updated.Title)
|
||||
}
|
||||
|
||||
if updated.URL != updateInput.URL {
|
||||
t.Errorf("Expected URL %s, got %s", updateInput.URL, updated.URL)
|
||||
}
|
||||
|
||||
if updated.Checksum != updateInput.Checksum {
|
||||
t.Errorf("Expected Checksum %s, got %s", updateInput.Checksum, updated.Checksum)
|
||||
}
|
||||
|
||||
if updated.ChecksumAlgorithm != updateInput.ChecksumAlgorithm {
|
||||
t.Errorf("Expected ChecksumAlgorithm %s, got %s", updateInput.ChecksumAlgorithm, updated.ChecksumAlgorithm)
|
||||
}
|
||||
|
||||
if updated.Description != updateInput.Description {
|
||||
t.Errorf("Expected Description %s, got %s", updateInput.Description, updated.Description)
|
||||
}
|
||||
|
||||
// CreatedBy should remain unchanged
|
||||
if updated.CreatedBy != created.CreatedBy {
|
||||
t.Errorf("Expected CreatedBy to remain %s, got %s", created.CreatedBy, updated.CreatedBy)
|
||||
}
|
||||
|
||||
// UpdatedAt should be later than CreatedAt
|
||||
if !updated.UpdatedAt.After(created.CreatedAt) && !updated.UpdatedAt.Equal(created.CreatedAt) {
|
||||
t.Error("UpdatedAt should be after or equal to CreatedAt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentRepository_CreateOrUpdate(t *testing.T) {
|
||||
testDB := SetupTestDB(t)
|
||||
setupDocumentsTable(t, testDB)
|
||||
|
||||
ctx := context.Background()
|
||||
repo := NewDocumentRepository(testDB.DB)
|
||||
|
||||
input := models.DocumentInput{
|
||||
Title: "CreateOrUpdate Test",
|
||||
URL: "https://example.com/test.pdf",
|
||||
Checksum: "test123",
|
||||
ChecksumAlgorithm: "SHA-256",
|
||||
Description: "Test description",
|
||||
}
|
||||
|
||||
// First call should create
|
||||
doc1, err := repo.CreateOrUpdate(ctx, "upsert-doc-001", input, "creator@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create document: %v", err)
|
||||
}
|
||||
|
||||
if doc1.Title != input.Title {
|
||||
t.Errorf("Expected Title %s, got %s", input.Title, doc1.Title)
|
||||
}
|
||||
|
||||
// Second call with same doc_id should update
|
||||
updateInput := models.DocumentInput{
|
||||
Title: "Updated via Upsert",
|
||||
URL: "https://example.com/updated.pdf",
|
||||
Checksum: "updated789",
|
||||
ChecksumAlgorithm: "SHA-512",
|
||||
Description: "Updated description",
|
||||
}
|
||||
|
||||
doc2, err := repo.CreateOrUpdate(ctx, "upsert-doc-001", updateInput, "updater@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update document: %v", err)
|
||||
}
|
||||
|
||||
if doc2.Title != updateInput.Title {
|
||||
t.Errorf("Expected Title %s, got %s", updateInput.Title, doc2.Title)
|
||||
}
|
||||
|
||||
if doc2.URL != updateInput.URL {
|
||||
t.Errorf("Expected URL %s, got %s", updateInput.URL, doc2.URL)
|
||||
}
|
||||
|
||||
// Verify only one record exists
|
||||
retrieved, err := repo.GetByDocID(ctx, "upsert-doc-001")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get document: %v", err)
|
||||
}
|
||||
|
||||
if retrieved.Title != updateInput.Title {
|
||||
t.Errorf("Expected final Title %s, got %s", updateInput.Title, retrieved.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentRepository_Delete(t *testing.T) {
|
||||
testDB := SetupTestDB(t)
|
||||
setupDocumentsTable(t, testDB)
|
||||
|
||||
ctx := context.Background()
|
||||
repo := NewDocumentRepository(testDB.DB)
|
||||
|
||||
input := models.DocumentInput{
|
||||
Title: "Delete Test",
|
||||
URL: "https://example.com/delete.pdf",
|
||||
Checksum: "delete123",
|
||||
ChecksumAlgorithm: "SHA-256",
|
||||
Description: "Document to be deleted",
|
||||
}
|
||||
|
||||
_, err := repo.Create(ctx, "delete-doc-001", input, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create document: %v", err)
|
||||
}
|
||||
|
||||
err = repo.Delete(ctx, "delete-doc-001")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete document: %v", err)
|
||||
}
|
||||
|
||||
// Verify deletion
|
||||
retrieved, err := repo.GetByDocID(ctx, "delete-doc-001")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error when getting deleted document, got %v", err)
|
||||
}
|
||||
|
||||
if retrieved != nil {
|
||||
t.Error("Expected nil after deletion")
|
||||
}
|
||||
|
||||
// Test deleting non-existent document
|
||||
err = repo.Delete(ctx, "non-existent-doc")
|
||||
if err == nil {
|
||||
t.Error("Expected error when deleting non-existent document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentRepository_List(t *testing.T) {
|
||||
testDB := SetupTestDB(t)
|
||||
setupDocumentsTable(t, testDB)
|
||||
|
||||
ctx := context.Background()
|
||||
repo := NewDocumentRepository(testDB.DB)
|
||||
|
||||
// Create multiple documents
|
||||
for i := 1; i <= 5; i++ {
|
||||
input := models.DocumentInput{
|
||||
Title: "Document " + string(rune('A'+i-1)),
|
||||
URL: "https://example.com/doc" + string(rune('0'+i)) + ".pdf",
|
||||
Checksum: "checksum" + string(rune('0'+i)),
|
||||
ChecksumAlgorithm: "SHA-256",
|
||||
Description: "Test document " + string(rune('0'+i)),
|
||||
}
|
||||
|
||||
_, err := repo.Create(ctx, "list-doc-00"+string(rune('0'+i)), input, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create document %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test listing all
|
||||
docs, err := repo.List(ctx, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list documents: %v", err)
|
||||
}
|
||||
|
||||
if len(docs) != 5 {
|
||||
t.Errorf("Expected 5 documents, got %d", len(docs))
|
||||
}
|
||||
|
||||
// Test pagination
|
||||
page1, err := repo.List(ctx, 2, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get page 1: %v", err)
|
||||
}
|
||||
|
||||
if len(page1) != 2 {
|
||||
t.Errorf("Expected 2 documents in page 1, got %d", len(page1))
|
||||
}
|
||||
|
||||
page2, err := repo.List(ctx, 2, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get page 2: %v", err)
|
||||
}
|
||||
|
||||
if len(page2) != 2 {
|
||||
t.Errorf("Expected 2 documents in page 2, got %d", len(page2))
|
||||
}
|
||||
|
||||
// Verify ordering (newest first)
|
||||
if len(docs) >= 2 {
|
||||
if docs[0].CreatedAt.Before(docs[1].CreatedAt) {
|
||||
t.Error("Documents should be ordered by created_at DESC")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/btouchard/ackify-ce/internal/domain/models"
|
||||
"github.com/btouchard/ackify-ce/internal/infrastructure/database"
|
||||
"github.com/btouchard/ackify-ce/pkg/logger"
|
||||
)
|
||||
|
||||
type DocumentHandlers struct {
|
||||
documentRepo *database.DocumentRepository
|
||||
userService userService
|
||||
}
|
||||
|
||||
func NewDocumentHandlers(
|
||||
documentRepo *database.DocumentRepository,
|
||||
userService userService,
|
||||
) *DocumentHandlers {
|
||||
return &DocumentHandlers{
|
||||
documentRepo: documentRepo,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetDocumentMetadata retrieves document metadata as JSON
|
||||
func (h *DocumentHandlers) HandleGetDocumentMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
docID := chi.URLParam(r, "docID")
|
||||
|
||||
if docID == "" {
|
||||
http.Error(w, "Document ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := h.documentRepo.GetByDocID(ctx, docID)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to get document metadata", "error", err.Error(), "doc_id", docID)
|
||||
http.Error(w, "Failed to get document metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(doc); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUpdateDocumentMetadata creates or updates document metadata
|
||||
func (h *DocumentHandlers) HandleUpdateDocumentMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
docID := chi.URLParam(r, "docID")
|
||||
|
||||
if docID == "" {
|
||||
http.Error(w, "Document ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUser(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
input := models.DocumentInput{
|
||||
Title: r.FormValue("title"),
|
||||
URL: r.FormValue("url"),
|
||||
Checksum: r.FormValue("checksum"),
|
||||
ChecksumAlgorithm: r.FormValue("checksum_algorithm"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
// Validate checksum algorithm
|
||||
validAlgorithms := map[string]bool{
|
||||
"SHA-256": true,
|
||||
"SHA-512": true,
|
||||
"MD5": true,
|
||||
}
|
||||
|
||||
if input.ChecksumAlgorithm != "" && !validAlgorithms[input.ChecksumAlgorithm] {
|
||||
http.Error(w, "Invalid checksum algorithm. Must be SHA-256, SHA-512, or MD5", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Default to SHA-256 if not specified
|
||||
if input.ChecksumAlgorithm == "" {
|
||||
input.ChecksumAlgorithm = "SHA-256"
|
||||
}
|
||||
|
||||
doc, err := h.documentRepo.CreateOrUpdate(ctx, docID, input, user.Email)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to update document metadata", "error", err.Error(), "doc_id", docID)
|
||||
http.Error(w, "Failed to update document metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Logger.Info("Document metadata updated", "doc_id", docID, "updated_by", user.Email)
|
||||
|
||||
// Return JSON response for AJAX requests
|
||||
if r.Header.Get("Accept") == "application/json" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(doc); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect back to document page for form submissions
|
||||
http.Redirect(w, r, "/admin/docs/"+docID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleDeleteDocumentMetadata deletes document metadata
|
||||
func (h *DocumentHandlers) HandleDeleteDocumentMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
docID := chi.URLParam(r, "docID")
|
||||
|
||||
if docID == "" {
|
||||
http.Error(w, "Document ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUser(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.documentRepo.Delete(ctx, docID)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to delete document metadata", "error", err.Error(), "doc_id", docID)
|
||||
http.Error(w, "Failed to delete document metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Logger.Info("Document metadata deleted", "doc_id", docID, "deleted_by", user.Email)
|
||||
|
||||
// Return success for AJAX requests
|
||||
if r.Header.Get("Accept") == "application/json" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect back to document page for form submissions
|
||||
http.Redirect(w, r, "/admin/docs/"+docID, http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Remove name field from expected_signers table
|
||||
ALTER TABLE expected_signers DROP COLUMN name;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add name field to expected_signers table
|
||||
-- This allows storing optional display names for expected readers
|
||||
-- Supports formats like "Benjamin Touchard <benjamin@kolapsis.com>"
|
||||
ALTER TABLE expected_signers ADD COLUMN name TEXT NOT NULL DEFAULT '';
|
||||
|
||||
COMMENT ON COLUMN expected_signers.name IS 'Optional display name for personalized reminder emails';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-- Drop trigger and function
|
||||
DROP TRIGGER IF EXISTS trigger_update_documents_updated_at ON documents;
|
||||
DROP FUNCTION IF EXISTS update_documents_updated_at();
|
||||
|
||||
-- Drop table
|
||||
DROP TABLE IF EXISTS documents;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-- Create documents table for document metadata
|
||||
CREATE TABLE documents (
|
||||
doc_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
checksum TEXT NOT NULL DEFAULT '',
|
||||
checksum_algorithm TEXT NOT NULL DEFAULT 'SHA-256' CHECK (checksum_algorithm IN ('SHA-256', 'SHA-512', 'MD5')),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
COMMENT ON TABLE documents IS 'Stores document metadata including URL, checksum, and description';
|
||||
COMMENT ON COLUMN documents.doc_id IS 'Document identifier (references signatures.doc_id)';
|
||||
COMMENT ON COLUMN documents.title IS 'Optional document title';
|
||||
COMMENT ON COLUMN documents.url IS 'URL or path to the document';
|
||||
COMMENT ON COLUMN documents.checksum IS 'Checksum/hash of the document for integrity verification';
|
||||
COMMENT ON COLUMN documents.checksum_algorithm IS 'Algorithm used for checksum (SHA-256, SHA-512, or MD5)';
|
||||
COMMENT ON COLUMN documents.description IS 'Optional document description';
|
||||
COMMENT ON COLUMN documents.created_at IS 'Timestamp when document metadata was created';
|
||||
COMMENT ON COLUMN documents.updated_at IS 'Timestamp when document metadata was last updated';
|
||||
COMMENT ON COLUMN documents.created_by IS 'Email of user who created the document metadata';
|
||||
|
||||
-- Create index on created_at for sorting
|
||||
CREATE INDEX idx_documents_created_at ON documents(created_at DESC);
|
||||
|
||||
-- Create trigger to automatically update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_documents_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_documents_updated_at
|
||||
BEFORE UPDATE ON documents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_documents_updated_at();
|
||||
Reference in New Issue
Block a user