Merged in feature/download-pdf (pull request #216)

retrieve document by id and tests

* working

* missing files
This commit is contained in:
Jay Brown
2026-03-17 17:06:57 +00:00
parent aafe7d5b5f
commit 2eff52877b
16 changed files with 1152 additions and 343 deletions
+77
View File
@@ -0,0 +1,77 @@
package documentdownload
import (
"context"
"fmt"
"io"
"mime"
"path/filepath"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/google/uuid"
)
// DownloadResult contains the data needed to stream a document file to the client.
type DownloadResult struct {
Body io.ReadCloser // S3 object body - caller must close
Filename string // Original filename (for Content-Disposition)
ContentType string // MIME type detected from filename extension
Size int64 // File size in bytes (for Content-Length)
}
// GetDownload retrieves the document file bytes from S3.
// Returns a DownloadResult with a streaming body that the caller must close.
func (s *Service) GetDownload(ctx context.Context, id uuid.UUID) (*DownloadResult, error) {
// Get document metadata (filename) from DB
doc, err := s.cfg.GetDBQueries().GetDocumentEnriched(ctx, id)
if err != nil {
return nil, fmt.Errorf("document not found: %w", err)
}
// Get S3 entry (bucket, key) from documentEntries
entry, err := s.cfg.GetDBQueries().GetDocumentEntry(ctx, id)
if err != nil {
return nil, fmt.Errorf("no S3 entry for document: %w", err)
}
// GetObject from S3
output, err := s.cfg.GetStoreClient().GetObject(ctx, &s3.GetObjectInput{
Bucket: &entry.Bucket,
Key: &entry.Key,
})
if err != nil {
return nil, fmt.Errorf("failed to retrieve file from S3: %w", err)
}
// Detect MIME type from filename extension
filename := ""
if doc.Filename != nil {
filename = *doc.Filename
}
contentType := detectContentType(filename)
// Get file size
var size int64
if output.ContentLength != nil {
size = *output.ContentLength
}
return &DownloadResult{
Body: output.Body,
Filename: filename,
ContentType: contentType,
Size: size,
}, nil
}
// detectContentType determines the MIME type based on file extension.
func detectContentType(filename string) string {
if filename == "" {
return "application/octet-stream"
}
contentType := mime.TypeByExtension(filepath.Ext(filename))
if contentType == "" {
return "application/octet-stream"
}
return contentType
}
+185
View File
@@ -0,0 +1,185 @@
package documentdownload_test
import (
"bytes"
"io"
"testing"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"queryorchestration/internal/client"
"queryorchestration/internal/database/repository"
documentdownload "queryorchestration/internal/document/download"
"queryorchestration/internal/serviceconfig"
"queryorchestration/internal/serviceconfig/objectstore"
"queryorchestration/internal/test"
)
type Config struct {
serviceconfig.BaseConfig
objectstore.ObjectStoreConfig
}
// createTestClient creates a client using the client service, which automatically creates the root folder.
func createTestClient(t *testing.T, cfg *Config, clientID, clientName string) {
t.Helper()
clientSvc := client.New(cfg)
_, err := clientSvc.Create(t.Context(), client.CreateParams{
ID: clientID,
Name: clientName,
})
require.NoError(t, err)
}
// setupTestDocument creates a document in DB, uploads content to S3, and creates the document entry.
// Returns the document ID.
func setupTestDocument(t *testing.T, cfg *Config, clientID, hash string, filename *string, content []byte) uuid.UUID {
t.Helper()
docID, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{
Clientid: clientID,
Hash: hash,
Filename: filename,
})
require.NoError(t, err)
bucket := cfg.GetBucket()
s3Key := "test/download/" + docID.String()
_, err = cfg.GetStoreClient().PutObject(t.Context(), &s3.PutObjectInput{
Bucket: &bucket,
Key: &s3Key,
Body: bytes.NewReader(content),
})
require.NoError(t, err)
err = cfg.GetDBQueries().AddDocumentEntry(t.Context(), &repository.AddDocumentEntryParams{
Documentid: docID,
Bucket: bucket,
Key: s3Key,
})
require.NoError(t, err)
return docID
}
func TestDownloadDocument_Success(t *testing.T) {
t.Parallel()
cfg := &Config{}
test.CreateDB(t, cfg)
test.CreateAWSResources(t, cfg)
createTestClient(t, cfg, "dl_success_client", "Download Success Client")
filename := "test-report.pdf"
fileContent := []byte("test file content for download verification")
docID := setupTestDocument(t, cfg, "dl_success_client", "dl_success_hash", &filename, fileContent)
svc := documentdownload.New(cfg)
result, err := svc.GetDownload(t.Context(), docID)
require.NoError(t, err)
defer result.Body.Close()
body, err := io.ReadAll(result.Body)
require.NoError(t, err)
assert.Equal(t, fileContent, body)
assert.Equal(t, "test-report.pdf", result.Filename)
assert.Equal(t, "application/pdf", result.ContentType)
}
func TestDownloadDocument_NotFound(t *testing.T) {
t.Parallel()
cfg := &Config{}
test.CreateDB(t, cfg)
test.CreateAWSResources(t, cfg)
svc := documentdownload.New(cfg)
_, err := svc.GetDownload(t.Context(), uuid.New())
require.Error(t, err)
assert.Contains(t, err.Error(), "document not found")
}
func TestDownloadDocument_NoS3Entry(t *testing.T) {
t.Parallel()
cfg := &Config{}
test.CreateDB(t, cfg)
test.CreateAWSResources(t, cfg)
createTestClient(t, cfg, "dl_noentry_client", "Download NoEntry Client")
// Create document but do NOT create a document entry or upload to S3
docID, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{
Clientid: "dl_noentry_client",
Hash: "dl_noentry_hash",
})
require.NoError(t, err)
svc := documentdownload.New(cfg)
_, err = svc.GetDownload(t.Context(), docID)
require.Error(t, err)
assert.Contains(t, err.Error(), "no S3 entry")
}
func TestDownloadDocument_MimeTypeDetection(t *testing.T) {
t.Parallel()
cfg := &Config{}
test.CreateDB(t, cfg)
test.CreateAWSResources(t, cfg)
createTestClient(t, cfg, "dl_mime_client", "Download Mime Client")
tests := []struct {
name string
filename *string
expectedMime string
}{
{
name: "PDF file",
filename: strPtr("report.pdf"),
expectedMime: "application/pdf",
},
{
name: "PNG image",
filename: strPtr("photo.png"),
expectedMime: "image/png",
},
{
name: "CSV file",
filename: strPtr("data.csv"),
expectedMime: "text/csv; charset=utf-8",
},
{
name: "nil filename defaults to octet-stream",
filename: nil,
expectedMime: "application/octet-stream",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hash := "dl_mime_hash_" + uuid.New().String()[:8]
content := []byte("file content " + tc.name)
docID := setupTestDocument(t, cfg, "dl_mime_client", hash, tc.filename, content)
svc := documentdownload.New(cfg)
result, err := svc.GetDownload(t.Context(), docID)
require.NoError(t, err)
defer result.Body.Close()
body, err := io.ReadAll(result.Body)
require.NoError(t, err)
assert.Equal(t, tc.expectedMime, result.ContentType)
assert.Equal(t, content, body)
})
}
}
func strPtr(s string) *string {
return &s
}
+22
View File
@@ -0,0 +1,22 @@
package documentdownload
import (
"queryorchestration/internal/serviceconfig"
"queryorchestration/internal/serviceconfig/objectstore"
)
// ConfigProvider combines database and S3 access for download operations.
type ConfigProvider interface {
serviceconfig.ConfigProvider
objectstore.ConfigProvider
}
// Service handles downloading original document files from S3.
type Service struct {
cfg ConfigProvider
}
// New creates a new download service.
func New(cfg ConfigProvider) *Service {
return &Service{cfg: cfg}
}