feat(services): Add book matching and collection services
- Add book matching service for intelligent book deduplication - Add collection service for collection management - Add test files for book matching and collections
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// BookMatch represents a potential match with confidence score
|
||||
type BookMatch struct {
|
||||
MediaItemID uuid.UUID `json:"media_item_id"`
|
||||
BookmannUUID uuid.UUID `json:"bookmann_uuid"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
MatchMethod string `json:"match_method"`
|
||||
}
|
||||
|
||||
// BookQueryRequest represents a book query from a device
|
||||
type BookQueryRequest struct {
|
||||
Identifiers []string `json:"identifiers"` // ["isbn:...", "uuid:...", "opf_uuid:..."]
|
||||
SHA256 string `json:"sha256"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
}
|
||||
|
||||
// BookQueryResponse represents the response to a book query
|
||||
type BookQueryResponse struct {
|
||||
Matches []BookMatch `json:"matches"`
|
||||
Action string `json:"action"` // "auto_link", "multiple_matches", "no_match"
|
||||
}
|
||||
|
||||
// LinkBookRequest represents a manual linking request
|
||||
type LinkBookRequest struct {
|
||||
DeviceFile struct {
|
||||
FilePath string `json:"file_path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Title string `json:"title"`
|
||||
} `json:"device_file"`
|
||||
MediaItemID uuid.UUID `json:"media_item_id"`
|
||||
ConfidenceScore float64 `json:"confidence_score"`
|
||||
}
|
||||
|
||||
// BookMatchingService handles universal book matching
|
||||
type BookMatchingService struct {
|
||||
db *database.Queries
|
||||
}
|
||||
|
||||
// NewBookMatchingService creates a new book matching service
|
||||
func NewBookMatchingService(db *database.Queries) *BookMatchingService {
|
||||
return &BookMatchingService{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// QueryBooks queries for a book using multiple identifier types with confidence scoring
|
||||
func (s *BookMatchingService) QueryBooks(ctx context.Context, req *BookQueryRequest) (*BookQueryResponse, error) {
|
||||
var matches []BookMatch
|
||||
|
||||
// Priority 1: Bookmann UUID (canonical) - Confidence: 1.0
|
||||
if match := s.matchByBookmannUUID(ctx, req.Identifiers); match != nil {
|
||||
matches = append(matches, *match)
|
||||
}
|
||||
|
||||
// Priority 2: OPF UUID (from EPUB metadata) - Confidence: 0.95
|
||||
if match := s.matchByOPFUUID(ctx, req.Identifiers); match != nil {
|
||||
matches = append(matches, *match)
|
||||
}
|
||||
|
||||
// Priority 3: SHA-256 hash - Confidence: 0.9
|
||||
if req.SHA256 != "" {
|
||||
if match := s.matchBySHA256(ctx, req.SHA256); match != nil {
|
||||
matches = append(matches, *match)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: OPF identifier (non-UUID) - Confidence: 0.85
|
||||
if match := s.matchByOPFIdentifier(ctx, req.Identifiers); match != nil {
|
||||
matches = append(matches, *match)
|
||||
}
|
||||
|
||||
// Priority 5: ISBN/ASIN - Confidence: 0.8
|
||||
if match := s.matchByISBNASIN(ctx, req.Identifiers); match != nil {
|
||||
matches = append(matches, *match)
|
||||
}
|
||||
|
||||
// Priority 6: File path (device-specific) - Need device_id for this
|
||||
// This will be handled at API layer with device context
|
||||
|
||||
// Priority 7: Title + author + file size - Confidence: 0.5
|
||||
if req.Title != "" && req.Author != "" && req.FileSize > 0 {
|
||||
if matches := s.matchByTitleAuthorSize(ctx, req.Title, req.Author, req.FileSize); len(matches) > 0 {
|
||||
matches = append(matches, matches...)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 8: Title only (last resort) - Confidence: 0.3
|
||||
if req.Title != "" && len(matches) == 0 {
|
||||
if matches := s.matchByTitleOnly(ctx, req.Title); len(matches) > 0 {
|
||||
matches = append(matches, matches...)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine action
|
||||
action := s.determineAction(matches)
|
||||
|
||||
return &BookQueryResponse{
|
||||
Matches: matches,
|
||||
Action: action,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// matchByBookmannUUID attempts to match by Bookmann UUID
|
||||
func (s *BookMatchingService) matchByBookmannUUID(ctx context.Context, identifiers []string) *BookMatch {
|
||||
for _, id := range identifiers {
|
||||
if len(id) > 4 && id[:4] == "uuid:" {
|
||||
uuidStr := id[5:]
|
||||
parsedUUID, err := uuid.Parse(uuidStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if media item exists
|
||||
item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
if err == nil {
|
||||
mediaUUID := item.ID.Bytes
|
||||
return &BookMatch{
|
||||
MediaItemID: mediaUUID,
|
||||
BookmannUUID: mediaUUID,
|
||||
Confidence: 1.0,
|
||||
MatchMethod: "uuid_match",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchByOPFUUID attempts to match by OPF UUID
|
||||
func (s *BookMatchingService) matchByOPFUUID(ctx context.Context, identifiers []string) *BookMatch {
|
||||
for _, id := range identifiers {
|
||||
if len(id) > 8 && id[:8] == "opf_uuid:" {
|
||||
uuidStr := id[9:]
|
||||
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item.OpfUuid.Valid && item.OpfUuid.String == uuidStr {
|
||||
return &BookMatch{
|
||||
MediaItemID: item.ID.Bytes,
|
||||
BookmannUUID: item.ID.Bytes,
|
||||
Confidence: 0.95,
|
||||
MatchMethod: "opf_uuid_match",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchBySHA256 attempts to match by file SHA-256 hash
|
||||
func (s *BookMatchingService) matchBySHA256(ctx context.Context, sha256 string) *BookMatch {
|
||||
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
||||
Limit: 1000,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item.FileSha256.Valid && item.FileSha256.String == sha256 {
|
||||
return &BookMatch{
|
||||
MediaItemID: item.ID.Bytes,
|
||||
BookmannUUID: item.ID.Bytes,
|
||||
Confidence: 0.9,
|
||||
MatchMethod: "sha256_match",
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchByOPFIdentifier attempts to match by OPF identifier
|
||||
func (s *BookMatchingService) matchByOPFIdentifier(ctx context.Context, identifiers []string) *BookMatch {
|
||||
for _, id := range identifiers {
|
||||
if len(id) > 15 && id[:15] == "opf_identifier:" {
|
||||
identifier := id[16:]
|
||||
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
||||
Limit: 1000,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item.OpfIdentifier.Valid && item.OpfIdentifier.String == identifier {
|
||||
return &BookMatch{
|
||||
MediaItemID: item.ID.Bytes,
|
||||
BookmannUUID: item.ID.Bytes,
|
||||
Confidence: 0.85,
|
||||
MatchMethod: "opf_identifier_match",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchByISBNASIN attempts to match by ISBN or ASIN
|
||||
func (s *BookMatchingService) matchByISBNASIN(ctx context.Context, identifiers []string) *BookMatch {
|
||||
for _, id := range identifiers {
|
||||
if len(id) > 5 && id[:5] == "isbn:" {
|
||||
isbn := id[6:]
|
||||
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
||||
Limit: 1000,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item.Isbn.Valid && item.Isbn.String == isbn {
|
||||
return &BookMatch{
|
||||
MediaItemID: item.ID.Bytes,
|
||||
BookmannUUID: item.ID.Bytes,
|
||||
Confidence: 0.8,
|
||||
MatchMethod: "isbn_match",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(id) > 5 && id[:5] == "asin:" {
|
||||
asin := id[6:]
|
||||
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
||||
Limit: 1000,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item.Asin.Valid && item.Asin.String == asin {
|
||||
return &BookMatch{
|
||||
MediaItemID: item.ID.Bytes,
|
||||
BookmannUUID: item.ID.Bytes,
|
||||
Confidence: 0.8,
|
||||
MatchMethod: "asin_match",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchByTitleAuthorSize attempts to match by title, author, and file size
|
||||
func (s *BookMatchingService) matchByTitleAuthorSize(ctx context.Context, title, author string, fileSize int64) []BookMatch {
|
||||
var matches []BookMatch
|
||||
|
||||
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
||||
Limit: 1000,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return matches
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
// Check title match (case-insensitive)
|
||||
titleMatch := item.Title == title
|
||||
|
||||
// Check author match (case-insensitive)
|
||||
authorMatch := item.Author.Valid && item.Author.String == author
|
||||
|
||||
// Check file size within 10%
|
||||
sizeMatch := item.FileSize.Valid && math.Abs(float64(item.FileSize.Int64-fileSize))/float64(fileSize) <= 0.1
|
||||
|
||||
if titleMatch && authorMatch && sizeMatch {
|
||||
matches = append(matches, BookMatch{
|
||||
MediaItemID: item.ID.Bytes,
|
||||
BookmannUUID: item.ID.Bytes,
|
||||
Confidence: 0.5,
|
||||
MatchMethod: "title_author_size_match",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// matchByTitleOnly attempts to match by title only (last resort)
|
||||
func (s *BookMatchingService) matchByTitleOnly(ctx context.Context, title string) []BookMatch {
|
||||
var matches []BookMatch
|
||||
|
||||
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
||||
Limit: 1000,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return matches
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item.Title == title {
|
||||
matches = append(matches, BookMatch{
|
||||
MediaItemID: item.ID.Bytes,
|
||||
BookmannUUID: item.ID.Bytes,
|
||||
Confidence: 0.3,
|
||||
MatchMethod: "title_match",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// determineAction determines the action to take based on matches
|
||||
func (s *BookMatchingService) determineAction(matches []BookMatch) string {
|
||||
if len(matches) == 0 {
|
||||
return "no_match"
|
||||
}
|
||||
|
||||
if len(matches) == 1 {
|
||||
// Auto-link if confidence is high enough (> 0.7)
|
||||
if matches[0].Confidence > 0.7 {
|
||||
return "auto_link"
|
||||
}
|
||||
}
|
||||
|
||||
return "multiple_matches"
|
||||
}
|
||||
|
||||
// LinkBook manually links a device file to a media item
|
||||
func (s *BookMatchingService) LinkBook(ctx context.Context, deviceID uuid.UUID, req *LinkBookRequest) (*database.DeviceFileAliases, error) {
|
||||
// Create device file alias
|
||||
alias, err := s.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: req.MediaItemID, Valid: true},
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
FilePath: req.DeviceFile.FilePath,
|
||||
FileSha256: pgtype.Text{String: req.DeviceFile.SHA256, Valid: req.DeviceFile.SHA256 != ""},
|
||||
ConfidenceScore: pgtype.Float8{Float64: req.ConfidenceScore, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create device file alias: %v", err)
|
||||
}
|
||||
|
||||
return &alias, nil
|
||||
}
|
||||
|
||||
// GetUnlinkedBooks returns books that need manual linking
|
||||
func (s *BookMatchingService) GetUnlinkedBooks(ctx context.Context, deviceID uuid.UUID) ([]map[string]interface{}, error) {
|
||||
// Get device file aliases that don't have media_item_id set
|
||||
// This is a placeholder - actual implementation would query progress records
|
||||
// that exist without matching media items
|
||||
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestBookMatchingService_QueryBooks tests the book query functionality
|
||||
func TestBookMatchingService_QueryBooks(t *testing.T) {
|
||||
// This is a placeholder test that would need a mock database
|
||||
// For now, we'll test the matching priority logic structure
|
||||
|
||||
t.Run("Priority 1: Bookmann UUID match", func(t *testing.T) {
|
||||
// Test that UUID matching returns highest confidence
|
||||
req := &BookQueryRequest{
|
||||
Identifiers: []string{"uuid:550e8400-e29b-41d4-a716-446655440000"},
|
||||
}
|
||||
|
||||
// Would need mock DB to test actual matching
|
||||
_ = req
|
||||
t.Log("UUID matching test placeholder - requires mock database")
|
||||
})
|
||||
|
||||
t.Run("Priority 2: OPF UUID match", func(t *testing.T) {
|
||||
req := &BookQueryRequest{
|
||||
Identifiers: []string{"opf_uuid:550e8400-e29b-41d4-a716-446655440000"},
|
||||
}
|
||||
|
||||
_ = req
|
||||
t.Log("OPF UUID matching test placeholder - requires mock database")
|
||||
})
|
||||
|
||||
t.Run("Priority 3: SHA-256 match", func(t *testing.T) {
|
||||
req := &BookQueryRequest{
|
||||
SHA256: "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592",
|
||||
}
|
||||
|
||||
_ = req
|
||||
t.Log("SHA-256 matching test placeholder - requires mock database")
|
||||
})
|
||||
|
||||
t.Run("Priority 7: Title + author + size match", func(t *testing.T) {
|
||||
req := &BookQueryRequest{
|
||||
Title: "Test Book",
|
||||
Author: "Test Author",
|
||||
FileSize: 1024000,
|
||||
}
|
||||
|
||||
_ = req
|
||||
t.Log("Title+author+size matching test placeholder - requires mock database")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBookMatch_ConfidenceLevels tests confidence scoring
|
||||
func TestBookMatch_ConfidenceLevels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
match BookMatch
|
||||
confidence float64
|
||||
method string
|
||||
}{
|
||||
{
|
||||
name: "UUID match - highest confidence",
|
||||
match: BookMatch{
|
||||
Confidence: 1.0,
|
||||
MatchMethod: "uuid_match",
|
||||
},
|
||||
confidence: 1.0,
|
||||
method: "uuid_match",
|
||||
},
|
||||
{
|
||||
name: "SHA-256 match - high confidence",
|
||||
match: BookMatch{
|
||||
Confidence: 0.9,
|
||||
MatchMethod: "sha256_match",
|
||||
},
|
||||
confidence: 0.9,
|
||||
method: "sha256_match",
|
||||
},
|
||||
{
|
||||
name: "ISBN match - medium confidence",
|
||||
match: BookMatch{
|
||||
Confidence: 0.8,
|
||||
MatchMethod: "isbn_match",
|
||||
},
|
||||
confidence: 0.8,
|
||||
method: "isbn_match",
|
||||
},
|
||||
{
|
||||
name: "Title+author+size match - lower confidence",
|
||||
match: BookMatch{
|
||||
Confidence: 0.5,
|
||||
MatchMethod: "title_author_size_match",
|
||||
},
|
||||
confidence: 0.5,
|
||||
method: "title_author_size_match",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.match.Confidence != tt.confidence {
|
||||
t.Errorf("Expected confidence %f, got %f", tt.confidence, tt.match.Confidence)
|
||||
}
|
||||
|
||||
if tt.match.MatchMethod != tt.method {
|
||||
t.Errorf("Expected method %s, got %s", tt.method, tt.match.MatchMethod)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookQueryResponse_ActionDetermination tests action logic
|
||||
func TestBookQueryResponse_ActionDetermination(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response BookQueryResponse
|
||||
action string
|
||||
}{
|
||||
{
|
||||
name: "No matches",
|
||||
response: BookQueryResponse{
|
||||
Matches: []BookMatch{},
|
||||
},
|
||||
action: "no_match",
|
||||
},
|
||||
{
|
||||
name: "Single high-confidence match",
|
||||
response: BookQueryResponse{
|
||||
Matches: []BookMatch{
|
||||
{Confidence: 0.9, MatchMethod: "sha256_match"},
|
||||
},
|
||||
},
|
||||
action: "auto_link",
|
||||
},
|
||||
{
|
||||
name: "Single low-confidence match",
|
||||
response: BookQueryResponse{
|
||||
Matches: []BookMatch{
|
||||
{Confidence: 0.5, MatchMethod: "title_author_size_match"},
|
||||
},
|
||||
},
|
||||
action: "multiple_matches",
|
||||
},
|
||||
{
|
||||
name: "Multiple matches",
|
||||
response: BookQueryResponse{
|
||||
Matches: []BookMatch{
|
||||
{Confidence: 0.8, MatchMethod: "isbn_match"},
|
||||
{Confidence: 0.5, MatchMethod: "title_match"},
|
||||
},
|
||||
},
|
||||
action: "multiple_matches",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Determine action
|
||||
var action string
|
||||
if len(tt.response.Matches) == 0 {
|
||||
action = "no_match"
|
||||
} else if len(tt.response.Matches) == 1 && tt.response.Matches[0].Confidence > 0.7 {
|
||||
action = "auto_link"
|
||||
} else {
|
||||
action = "multiple_matches"
|
||||
}
|
||||
|
||||
if action != tt.action {
|
||||
t.Errorf("Expected action %s, got %s", tt.action, action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermineAction tests the action determination logic
|
||||
func TestDetermineAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
matches []BookMatch
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "No matches",
|
||||
matches: []BookMatch{},
|
||||
expected: "no_match",
|
||||
},
|
||||
{
|
||||
name: "Single high-confidence match",
|
||||
matches: []BookMatch{
|
||||
{Confidence: 0.9, MatchMethod: "sha256_match"},
|
||||
},
|
||||
expected: "auto_link",
|
||||
},
|
||||
{
|
||||
name: "Single low-confidence match",
|
||||
matches: []BookMatch{
|
||||
{Confidence: 0.5, MatchMethod: "title_match"},
|
||||
},
|
||||
expected: "multiple_matches",
|
||||
},
|
||||
{
|
||||
name: "Multiple matches",
|
||||
matches: []BookMatch{
|
||||
{Confidence: 0.8, MatchMethod: "isbn_match"},
|
||||
{Confidence: 0.7, MatchMethod: "asin_match"},
|
||||
},
|
||||
expected: "multiple_matches",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service := &BookMatchingService{}
|
||||
result := service.determineAction(tt.matches)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected action %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookQueryRequest_Validation tests request validation
|
||||
func TestBookQueryRequest_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req BookQueryRequest
|
||||
valid bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "Valid UUID identifier",
|
||||
req: BookQueryRequest{
|
||||
Identifiers: []string{"uuid:550e8400-e29b-41d4-a716-446655440000"},
|
||||
},
|
||||
valid: true,
|
||||
reason: "",
|
||||
},
|
||||
{
|
||||
name: "Valid ISBN identifier",
|
||||
req: BookQueryRequest{
|
||||
Identifiers: []string{"isbn:9783161484100"},
|
||||
},
|
||||
valid: true,
|
||||
reason: "",
|
||||
},
|
||||
{
|
||||
name: "Valid SHA-256",
|
||||
req: BookQueryRequest{
|
||||
SHA256: "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592",
|
||||
},
|
||||
valid: true,
|
||||
reason: "",
|
||||
},
|
||||
{
|
||||
name: "Valid title+author+size",
|
||||
req: BookQueryRequest{
|
||||
Title: "Test Book",
|
||||
Author: "Test Author",
|
||||
FileSize: 1024000,
|
||||
},
|
||||
valid: true,
|
||||
reason: "",
|
||||
},
|
||||
{
|
||||
name: "Empty request",
|
||||
req: BookQueryRequest{},
|
||||
valid: false,
|
||||
reason: "No identifiers provided",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Basic validation logic
|
||||
valid := len(tt.req.Identifiers) > 0 ||
|
||||
tt.req.SHA256 != "" ||
|
||||
(tt.req.Title != "" && tt.req.Author != "")
|
||||
|
||||
if valid != tt.valid {
|
||||
t.Errorf("Expected valid=%v, got valid=%v. Reason: %s", tt.valid, valid, tt.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLinkBookRequest_Validation tests linking request validation
|
||||
func TestLinkBookRequest_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req LinkBookRequest
|
||||
valid bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "Valid link request",
|
||||
req: LinkBookRequest{
|
||||
MediaItemID: uuid.New(),
|
||||
ConfidenceScore: 1.0,
|
||||
DeviceFile: struct {
|
||||
FilePath string `json:"file_path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Title string `json:"title"`
|
||||
}{
|
||||
FilePath: "/path/to/book.epub",
|
||||
SHA256: "abc123",
|
||||
Title: "Test Book",
|
||||
},
|
||||
},
|
||||
valid: true,
|
||||
reason: "",
|
||||
},
|
||||
{
|
||||
name: "Missing media item ID",
|
||||
req: LinkBookRequest{
|
||||
ConfidenceScore: 1.0,
|
||||
},
|
||||
valid: false,
|
||||
reason: "MediaItemID is required",
|
||||
},
|
||||
{
|
||||
name: "Invalid confidence score",
|
||||
req: LinkBookRequest{
|
||||
MediaItemID: uuid.New(),
|
||||
ConfidenceScore: 2.0, // Invalid, should be 0-1
|
||||
},
|
||||
valid: false,
|
||||
reason: "Confidence score must be between 0 and 1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Check if MediaItemID is valid
|
||||
valid := tt.req.MediaItemID != uuid.Nil &&
|
||||
tt.req.ConfidenceScore >= 0 &&
|
||||
tt.req.ConfidenceScore <= 1
|
||||
|
||||
if valid != tt.valid {
|
||||
t.Errorf("Expected valid=%v, got valid=%v. Reason: %s", tt.valid, valid, tt.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage
|
||||
func ExampleBookMatchingService_QueryBooks() {
|
||||
service := NewBookMatchingService(nil) // Would need real DB
|
||||
|
||||
req := &BookQueryRequest{
|
||||
Identifiers: []string{
|
||||
"uuid:550e8400-e29b-41d4-a716-446655440000",
|
||||
"isbn:9783161484100",
|
||||
},
|
||||
SHA256: "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592",
|
||||
Title: "Test Book",
|
||||
Author: "Test Author",
|
||||
FileSize: 1024000,
|
||||
}
|
||||
|
||||
_ = service
|
||||
_ = req
|
||||
// response, err := service.QueryBooks(context.Background(), req)
|
||||
// fmt.Printf("Matches: %d, Action: %s\n", len(response.Matches), response.Action)
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// Rule represents an auto-assign rule for collections
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
Field string `json:"field"` // "genre", "series", "author", "language", "publisher", "copyright_year", "tags"
|
||||
Operator string `json:"operator"` // "equals", "contains", "starts_with", "ends_with", "greater_than", "less_than"
|
||||
Value string `json:"value"`
|
||||
Priority int `json:"priority"` // 1-10, higher values take precedence
|
||||
}
|
||||
|
||||
// RuleEvaluation represents the result of evaluating a rule
|
||||
type RuleEvaluation struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
Matches bool `json:"matches"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// CollectionService handles collection management
|
||||
type CollectionService struct {
|
||||
db *database.Queries
|
||||
}
|
||||
|
||||
// NewCollectionService creates a new collection service
|
||||
func NewCollectionService(db *database.Queries) *CollectionService {
|
||||
return &CollectionService{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCollection creates a new collection
|
||||
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]interface{}) (database.Collections, error) {
|
||||
// Convert rules to JSONB ([]byte)
|
||||
var rulesJSON []byte
|
||||
if len(autoAssignRules) > 0 {
|
||||
var err error
|
||||
rulesJSON, err = json.Marshal(autoAssignRules)
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert view settings to JSONB ([]byte)
|
||||
var settingsJSON []byte
|
||||
if len(viewSettings) > 0 {
|
||||
var err error
|
||||
settingsJSON, err = json.Marshal(viewSettings)
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to marshal view settings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
collection, err := s.db.CreateCollection(ctx, database.CreateCollectionParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: name,
|
||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||
AutoAssignRules: rulesJSON,
|
||||
ViewSettings: settingsJSON,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to create collection: %v", err)
|
||||
}
|
||||
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
// GetCollection gets a collection by ID
|
||||
func (s *CollectionService) GetCollection(ctx context.Context, collectionID uuid.UUID) (database.Collections, error) {
|
||||
collection, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to get collection: %v", err)
|
||||
}
|
||||
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
// GetCollectionWithBookCount gets a collection with book count
|
||||
func (s *CollectionService) GetCollectionWithBookCount(ctx context.Context, collectionID uuid.UUID) (map[string]interface{}, error) {
|
||||
collection, err := s.db.GetCollectionWithBookCount(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get collection: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||
"name": collection.Name,
|
||||
"description": collection.Description.String,
|
||||
"color": collection.Color.String,
|
||||
"icon": collection.Icon.String,
|
||||
"auto_assign_rules": collection.AutoAssignRules,
|
||||
"view_settings": collection.ViewSettings,
|
||||
"created_at": collection.CreatedAt.Time.String(),
|
||||
"book_count": collection.BookCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserCollections gets all collections for a user
|
||||
func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.UUID) ([]database.Collections, error) {
|
||||
collections, err := s.db.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user collections: %v", err)
|
||||
}
|
||||
|
||||
return collections, nil
|
||||
}
|
||||
|
||||
// UpdateCollection updates a collection
|
||||
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]interface{}) (database.Collections, error) {
|
||||
// Convert rules to JSONB ([]byte)
|
||||
var rulesJSON []byte
|
||||
if len(autoAssignRules) > 0 {
|
||||
var err error
|
||||
rulesJSON, err = json.Marshal(autoAssignRules)
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert view settings to JSONB ([]byte)
|
||||
var settingsJSON []byte
|
||||
if len(viewSettings) > 0 {
|
||||
var err error
|
||||
settingsJSON, err = json.Marshal(viewSettings)
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to marshal view settings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
collection, err := s.db.UpdateCollection(ctx, database.UpdateCollectionParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
Name: name,
|
||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||
AutoAssignRules: rulesJSON,
|
||||
ViewSettings: settingsJSON,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to update collection: %v", err)
|
||||
}
|
||||
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
// DeleteCollection deletes a collection
|
||||
func (s *CollectionService) DeleteCollection(ctx context.Context, collectionID uuid.UUID) error {
|
||||
err := s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete collection: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddBookToCollection adds a book to a collection
|
||||
func (s *CollectionService) AddBookToCollection(ctx context.Context, collectionID, mediaItemID, userID uuid.UUID) error {
|
||||
_, err := s.db.AddBookToCollection(ctx, database.AddBookToCollectionParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
||||
AddedByUserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add book to collection: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveBookFromCollection removes a book from a collection
|
||||
func (s *CollectionService) RemoveBookFromCollection(ctx context.Context, collectionID, mediaItemID uuid.UUID) error {
|
||||
err := s.db.RemoveBookFromCollection(ctx, database.RemoveBookFromCollectionParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove book from collection: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCollectionBooks gets all books in a collection
|
||||
func (s *CollectionService) GetCollectionBooks(ctx context.Context, collectionID uuid.UUID) ([]database.GetCollectionItemsRow, error) {
|
||||
books, err := s.db.GetCollectionItems(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get collection books: %v", err)
|
||||
}
|
||||
|
||||
return books, nil
|
||||
}
|
||||
|
||||
// GetBookCollections gets all collections that contain a specific book
|
||||
func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID uuid.UUID) ([]database.Collections, error) {
|
||||
collections, err := s.db.GetCollectionsForBook(ctx, pgtype.UUID{Bytes: mediaItemID, Valid: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get book collections: %v", err)
|
||||
}
|
||||
|
||||
return collections, nil
|
||||
}
|
||||
|
||||
// EvaluateRules evaluates auto-assign rules for a media item
|
||||
func (s *CollectionService) EvaluateRules(mediaItem database.ListMediaItemsRow, rules []Rule) []RuleEvaluation {
|
||||
var evaluations []RuleEvaluation
|
||||
|
||||
// Sort rules by priority (higher priority first)
|
||||
sortedRules := make([]Rule, len(rules))
|
||||
copy(sortedRules, rules)
|
||||
for i := 0; i < len(sortedRules); i++ {
|
||||
for j := i + 1; j < len(sortedRules); j++ {
|
||||
if sortedRules[i].Priority < sortedRules[j].Priority {
|
||||
sortedRules[i], sortedRules[j] = sortedRules[j], sortedRules[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, rule := range sortedRules {
|
||||
eval := RuleEvaluation{
|
||||
RuleID: rule.ID,
|
||||
Matches: false,
|
||||
}
|
||||
|
||||
switch rule.Field {
|
||||
case "genre":
|
||||
if mediaItem.Genre.Valid {
|
||||
eval.Matches = s.evaluateRule(mediaItem.Genre.String, rule.Operator, rule.Value)
|
||||
}
|
||||
case "series":
|
||||
if mediaItem.Series.Valid {
|
||||
eval.Matches = s.evaluateRule(mediaItem.Series.String, rule.Operator, rule.Value)
|
||||
}
|
||||
case "author":
|
||||
if mediaItem.Author.Valid {
|
||||
eval.Matches = s.evaluateRule(mediaItem.Author.String, rule.Operator, rule.Value)
|
||||
}
|
||||
case "language":
|
||||
if mediaItem.Language.Valid {
|
||||
eval.Matches = s.evaluateRule(mediaItem.Language.String, rule.Operator, rule.Value)
|
||||
}
|
||||
case "publisher":
|
||||
if mediaItem.Publisher.Valid {
|
||||
eval.Matches = s.evaluateRule(mediaItem.Publisher.String, rule.Operator, rule.Value)
|
||||
}
|
||||
case "copyright_year":
|
||||
if mediaItem.CopyrightYear.Valid {
|
||||
yearStr := fmt.Sprintf("%d", mediaItem.CopyrightYear.Int32)
|
||||
eval.Matches = s.evaluateRule(yearStr, rule.Operator, rule.Value)
|
||||
}
|
||||
case "tags":
|
||||
if mediaItem.Tags.Valid {
|
||||
eval.Matches = s.evaluateRule(mediaItem.Tags.String, rule.Operator, rule.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate confidence based on rule type and priority
|
||||
if eval.Matches {
|
||||
baseConfidence := 0.7
|
||||
if rule.Field == "genre" || rule.Field == "series" {
|
||||
baseConfidence = 0.9
|
||||
} else if rule.Field == "author" || rule.Field == "publisher" {
|
||||
baseConfidence = 0.8
|
||||
}
|
||||
// Add priority boost (0.01 per priority point, max 0.1)
|
||||
priorityBoost := float64(rule.Priority) * 0.01
|
||||
if priorityBoost > 0.1 {
|
||||
priorityBoost = 0.1
|
||||
}
|
||||
eval.Confidence = baseConfidence + priorityBoost
|
||||
if eval.Confidence > 1.0 {
|
||||
eval.Confidence = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
evaluations = append(evaluations, eval)
|
||||
}
|
||||
|
||||
return evaluations
|
||||
}
|
||||
|
||||
// evaluateRule evaluates a single rule
|
||||
func (s *CollectionService) evaluateRule(fieldValue, operator, ruleValue string) bool {
|
||||
switch operator {
|
||||
case "equals":
|
||||
return containsIgnoreCase(fieldValue, ruleValue) && len(fieldValue) == len(ruleValue)
|
||||
case "contains":
|
||||
return containsIgnoreCase(fieldValue, ruleValue)
|
||||
case "starts_with":
|
||||
return hasPrefixIgnoreCase(fieldValue, ruleValue)
|
||||
case "ends_with":
|
||||
return hasSuffixIgnoreCase(fieldValue, ruleValue)
|
||||
case "greater_than":
|
||||
return compareNumbers(fieldValue, ruleValue, ">")
|
||||
case "less_than":
|
||||
return compareNumbers(fieldValue, ruleValue, "<")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// containsIgnoreCase checks if a string contains another (case-insensitive)
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
s = toLower(s)
|
||||
substr = toLower(substr)
|
||||
return contains(s, substr)
|
||||
}
|
||||
|
||||
// hasPrefixIgnoreCase checks if a string starts with a prefix (case-insensitive)
|
||||
func hasPrefixIgnoreCase(s, prefix string) bool {
|
||||
s = toLower(s)
|
||||
prefix = toLower(prefix)
|
||||
return hasPrefix(s, prefix)
|
||||
}
|
||||
|
||||
// hasSuffixIgnoreCase checks if a string ends with a suffix (case-insensitive)
|
||||
func hasSuffixIgnoreCase(s, suffix string) bool {
|
||||
s = toLower(s)
|
||||
suffix = toLower(suffix)
|
||||
return hasSuffix(s, suffix)
|
||||
}
|
||||
|
||||
// compareNumbers compares two numeric strings
|
||||
func compareNumbers(a, b, op string) bool {
|
||||
aFloat, errA := parseFloat(a)
|
||||
bFloat, errB := parseFloat(b)
|
||||
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch op {
|
||||
case ">":
|
||||
return aFloat > bFloat
|
||||
case "<":
|
||||
return aFloat < bFloat
|
||||
case ">=":
|
||||
return aFloat >= bFloat
|
||||
case "<=":
|
||||
return aFloat <= bFloat
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDeviceShelfMapping creates a shelf mapping for a device
|
||||
func (s *CollectionService) CreateDeviceShelfMapping(ctx context.Context, collectionID, deviceID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) {
|
||||
mapping, err := s.db.CreateDeviceShelfMapping(ctx, database.CreateDeviceShelfMappingParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
DeviceShelfName: pgtype.Text{String: deviceShelfName, Valid: deviceShelfName != ""},
|
||||
SyncDirection: pgtype.Text{String: syncDirection, Valid: syncDirection != ""},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return database.DeviceShelfMappings{}, fmt.Errorf("failed to create shelf mapping: %v", err)
|
||||
}
|
||||
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
// GetDeviceShelfMappings gets all shelf mappings for a device
|
||||
func (s *CollectionService) GetDeviceShelfMappings(ctx context.Context, deviceID uuid.UUID) ([]database.GetDeviceShelfMappingsRow, error) {
|
||||
mappings, err := s.db.GetDeviceShelfMappings(ctx, pgtype.UUID{Bytes: deviceID, Valid: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get shelf mappings: %v", err)
|
||||
}
|
||||
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
// UpdateDeviceShelfMapping updates a shelf mapping
|
||||
func (s *CollectionService) UpdateDeviceShelfMapping(ctx context.Context, mappingID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) {
|
||||
mapping, err := s.db.UpdateDeviceShelfMapping(ctx, database.UpdateDeviceShelfMappingParams{
|
||||
ID: pgtype.UUID{Bytes: mappingID, Valid: true},
|
||||
DeviceShelfName: pgtype.Text{String: deviceShelfName, Valid: deviceShelfName != ""},
|
||||
SyncDirection: pgtype.Text{String: syncDirection, Valid: syncDirection != ""},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return database.DeviceShelfMappings{}, fmt.Errorf("failed to update shelf mapping: %v", err)
|
||||
}
|
||||
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
// DeleteDeviceShelfMapping deletes a shelf mapping
|
||||
func (s *CollectionService) DeleteDeviceShelfMapping(ctx context.Context, mappingID uuid.UUID) error {
|
||||
err := s.db.DeleteDeviceShelfMapping(ctx, pgtype.UUID{Bytes: mappingID, Valid: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete shelf mapping: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyAutoAssignRules applies auto-assign rules for all collections to a media item
|
||||
func (s *CollectionService) ApplyAutoAssignRules(ctx context.Context, mediaItem database.ListMediaItemsRow, userID uuid.UUID) ([]uuid.UUID, error) {
|
||||
var matchedCollectionIDs []uuid.UUID
|
||||
|
||||
collections, err := s.GetUserCollections(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user collections: %v", err)
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
if len(collection.AutoAssignRules) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var rules []Rule
|
||||
if err := json.Unmarshal(collection.AutoAssignRules, &rules); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
evaluations := s.EvaluateRules(mediaItem, rules)
|
||||
|
||||
// Check if any rules match
|
||||
for _, eval := range evaluations {
|
||||
if eval.Matches && eval.Confidence > 0.7 {
|
||||
matchedCollectionIDs = append(matchedCollectionIDs, uuid.UUID(collection.ID.Bytes))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matchedCollectionIDs, nil
|
||||
}
|
||||
|
||||
// Helper functions (Go standard library functions)
|
||||
func toLower(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
|
||||
result := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
result[i] = c + 32
|
||||
} else {
|
||||
result[i] = c
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
||||
}
|
||||
|
||||
func hasPrefix(s, prefix string) bool {
|
||||
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
||||
}
|
||||
|
||||
func hasSuffix(s, suffix string) bool {
|
||||
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
|
||||
}
|
||||
|
||||
func indexOf(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func parseFloat(s string) (float64, error) {
|
||||
var result float64
|
||||
var sign float64 = 1
|
||||
var divisor float64 = 1
|
||||
var decimalPlaces int = 0
|
||||
var seenDecimal bool
|
||||
|
||||
// Skip leading whitespace
|
||||
start := 0
|
||||
for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n') {
|
||||
start++
|
||||
}
|
||||
|
||||
// Handle sign
|
||||
if start < len(s) && s[start] == '-' {
|
||||
sign = -1
|
||||
start++
|
||||
} else if start < len(s) && s[start] == '+' {
|
||||
start++
|
||||
}
|
||||
|
||||
// Parse digits
|
||||
for i := start; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= '0' && c <= '9' {
|
||||
result = result*10 + float64(c-'0')
|
||||
if seenDecimal {
|
||||
decimalPlaces++
|
||||
divisor *= 10
|
||||
}
|
||||
} else if c == '.' && !seenDecimal {
|
||||
seenDecimal = true
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return sign * result / divisor, nil
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func TestEvaluateRules_Equality(t *testing.T) {
|
||||
service := &CollectionService{}
|
||||
|
||||
mediaItem := database.ListMediaItemsRow{
|
||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
||||
}
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: "rule-1",
|
||||
Field: "genre",
|
||||
Operator: "equals",
|
||||
Value: "Science Fiction",
|
||||
Priority: 5,
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
|
||||
if len(evaluations) != 1 {
|
||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||
}
|
||||
|
||||
if !evaluations[0].Matches {
|
||||
t.Errorf("expected rule to match, but it didn't")
|
||||
}
|
||||
|
||||
if evaluations[0].Confidence <= 0.85 {
|
||||
t.Errorf("expected confidence > 0.85, got %f", evaluations[0].Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRules_NoMatch(t *testing.T) {
|
||||
service := &CollectionService{}
|
||||
|
||||
mediaItem := database.ListMediaItemsRow{
|
||||
Genre: pgtype.Text{String: "Fantasy", Valid: true},
|
||||
}
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: "rule-1",
|
||||
Field: "genre",
|
||||
Operator: "equals",
|
||||
Value: "Science Fiction",
|
||||
Priority: 5,
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
|
||||
if len(evaluations) != 1 {
|
||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||
}
|
||||
|
||||
if evaluations[0].Matches {
|
||||
t.Errorf("expected rule not to match, but it did")
|
||||
}
|
||||
|
||||
if evaluations[0].Confidence != 0 {
|
||||
t.Errorf("expected confidence 0, got %f", evaluations[0].Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRules_Contains(t *testing.T) {
|
||||
service := &CollectionService{}
|
||||
|
||||
mediaItem := database.ListMediaItemsRow{
|
||||
Series: pgtype.Text{String: "The Expanse", Valid: true},
|
||||
}
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: "rule-1",
|
||||
Field: "series",
|
||||
Operator: "contains",
|
||||
Value: "Expanse",
|
||||
Priority: 7,
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
|
||||
if len(evaluations) != 1 {
|
||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||
}
|
||||
|
||||
if !evaluations[0].Matches {
|
||||
t.Errorf("expected rule to match, but it didn't")
|
||||
}
|
||||
|
||||
if evaluations[0].Confidence <= 0.9 {
|
||||
t.Errorf("expected confidence > 0.9 with priority boost, got %f", evaluations[0].Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRules_PriorityOrder(t *testing.T) {
|
||||
service := &CollectionService{}
|
||||
|
||||
mediaItem := database.ListMediaItemsRow{
|
||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
||||
Series: pgtype.Text{String: "Foundation", Valid: true},
|
||||
}
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: "rule-1",
|
||||
Field: "genre",
|
||||
Operator: "equals",
|
||||
Value: "Science Fiction",
|
||||
Priority: 5,
|
||||
},
|
||||
{
|
||||
ID: "rule-2",
|
||||
Field: "series",
|
||||
Operator: "equals",
|
||||
Value: "Foundation",
|
||||
Priority: 9,
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
|
||||
if len(evaluations) != 2 {
|
||||
t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
|
||||
}
|
||||
|
||||
if evaluations[0].RuleID != "rule-2" {
|
||||
t.Errorf("expected rule-2 (higher priority) to be first, got %s", evaluations[0].RuleID)
|
||||
}
|
||||
|
||||
if evaluations[1].RuleID != "rule-1" {
|
||||
t.Errorf("expected rule-1 (lower priority) to be second, got %s", evaluations[1].RuleID)
|
||||
}
|
||||
|
||||
if evaluations[0].Confidence <= evaluations[1].Confidence {
|
||||
t.Errorf("expected higher priority rule to have higher confidence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRules_CopyrightYear(t *testing.T) {
|
||||
service := &CollectionService{}
|
||||
|
||||
mediaItem := database.ListMediaItemsRow{
|
||||
CopyrightYear: pgtype.Int4{Int32: 2020, Valid: true},
|
||||
}
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: "rule-1",
|
||||
Field: "copyright_year",
|
||||
Operator: "greater_than",
|
||||
Value: "2019",
|
||||
Priority: 5,
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
|
||||
if len(evaluations) != 1 {
|
||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||
}
|
||||
|
||||
if !evaluations[0].Matches {
|
||||
t.Errorf("expected rule to match (2020 > 2019), but it didn't")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRules_MissingField(t *testing.T) {
|
||||
service := &CollectionService{}
|
||||
|
||||
mediaItem := database.ListMediaItemsRow{}
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: "rule-1",
|
||||
Field: "genre",
|
||||
Operator: "equals",
|
||||
Value: "Science Fiction",
|
||||
Priority: 5,
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
|
||||
if len(evaluations) != 1 {
|
||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||
}
|
||||
|
||||
if evaluations[0].Matches {
|
||||
t.Errorf("expected rule not to match when field is missing, but it did")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRules_CaseInsensitive(t *testing.T) {
|
||||
service := &CollectionService{}
|
||||
|
||||
mediaItem := database.ListMediaItemsRow{
|
||||
Genre: pgtype.Text{String: "science fiction", Valid: true},
|
||||
}
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: "rule-1",
|
||||
Field: "genre",
|
||||
Operator: "equals",
|
||||
Value: "SCIENCE FICTION",
|
||||
Priority: 5,
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
|
||||
if len(evaluations) != 1 {
|
||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||
}
|
||||
|
||||
if !evaluations[0].Matches {
|
||||
t.Errorf("expected case-insensitive match, but it didn't match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRules_PriorityBoost(t *testing.T) {
|
||||
service := &CollectionService{}
|
||||
|
||||
mediaItem := database.ListMediaItemsRow{
|
||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
||||
}
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: "rule-1",
|
||||
Field: "genre",
|
||||
Operator: "equals",
|
||||
Value: "Science Fiction",
|
||||
Priority: 1,
|
||||
},
|
||||
{
|
||||
ID: "rule-2",
|
||||
Field: "genre",
|
||||
Operator: "equals",
|
||||
Value: "Science Fiction",
|
||||
Priority: 10,
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
|
||||
if len(evaluations) != 2 {
|
||||
t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
|
||||
}
|
||||
|
||||
// Higher priority (10) should give higher confidence than lower priority (1)
|
||||
// Genre base confidence is 0.9
|
||||
// Priority 1: 0.9 + 0.01 = 0.91
|
||||
// Priority 10: 0.9 + 0.1 = 1.0 (capped)
|
||||
if evaluations[0].Confidence != 1.0 {
|
||||
t.Errorf("expected higher priority confidence to be 1.0, got %f", evaluations[0].Confidence)
|
||||
}
|
||||
|
||||
if evaluations[1].Confidence != 0.91 {
|
||||
t.Errorf("expected lower priority confidence to be 0.91, got %f", evaluations[1].Confidence)
|
||||
}
|
||||
|
||||
// The higher priority rule should come first (sorted by priority descending)
|
||||
if evaluations[0].RuleID != "rule-2" {
|
||||
t.Errorf("expected higher priority rule to be first, got %s", evaluations[0].RuleID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCalculateFileSHA256 tests the SHA-256 calculation with streaming
|
||||
func TestCalculateFileSHA256(t *testing.T) {
|
||||
scanner := &EbookScanner{}
|
||||
|
||||
// Create a temporary test file
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
testContent := "The quick brown fox jumps over the lazy dog"
|
||||
|
||||
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Calculate expected hash
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(testContent))
|
||||
expectedHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
// Test the function
|
||||
calculatedHash, err := scanner.calculateFileSHA256(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("calculateFileSHA256 failed: %v", err)
|
||||
}
|
||||
|
||||
if calculatedHash != expectedHash {
|
||||
t.Errorf("Expected hash %s, got %s", expectedHash, calculatedHash)
|
||||
}
|
||||
|
||||
t.Logf("SHA-256 hash calculation successful: %s", calculatedHash)
|
||||
}
|
||||
|
||||
// TestCalculateFileSHA256LargeFile tests streaming with large file
|
||||
func TestCalculateFileSHA256LargeFile(t *testing.T) {
|
||||
scanner := &EbookScanner{}
|
||||
|
||||
// Create a temporary test file with larger content
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "large_test.txt")
|
||||
|
||||
// Create a 10MB file
|
||||
file, err := os.Create(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
testLine := strings.Repeat("This is a test line for SHA-256 calculation\n", 100)
|
||||
for i := 0; i < 1000; i++ {
|
||||
if _, err := writer.WriteString(testLine); err != nil {
|
||||
t.Fatalf("Failed to write to test file: %v", err)
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
|
||||
// Calculate expected hash
|
||||
file.Seek(0, 0)
|
||||
hasher := sha256.New()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := file.Read(buf)
|
||||
if err != nil && err != bufio.ErrBufferFull {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
t.Fatalf("Failed to read file for expected hash: %v", err)
|
||||
}
|
||||
hasher.Write(buf[:n])
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
expectedHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
// Test the function
|
||||
calculatedHash, err := scanner.calculateFileSHA256(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("calculateFileSHA256 failed for large file: %v", err)
|
||||
}
|
||||
|
||||
if calculatedHash != expectedHash {
|
||||
t.Errorf("Expected hash %s, got %s", expectedHash, calculatedHash)
|
||||
}
|
||||
|
||||
t.Logf("Large file SHA-256 hash calculation successful")
|
||||
}
|
||||
|
||||
// TestExtractISBNFromIdentifier tests ISBN extraction
|
||||
func TestExtractISBNFromIdentifier(t *testing.T) {
|
||||
scanner := &EbookScanner{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "ISBN with prefix",
|
||||
input: "isbn:978-3-16-148410-0",
|
||||
expected: "9783161484100",
|
||||
},
|
||||
{
|
||||
name: "ISBN with hyphens",
|
||||
input: "978-3-16-148410-0",
|
||||
expected: "9783161484100",
|
||||
},
|
||||
{
|
||||
name: "ISBN with spaces",
|
||||
input: "978 3 16 148410 0",
|
||||
expected: "9783161484100",
|
||||
},
|
||||
{
|
||||
name: "ISBN-10",
|
||||
input: "isbn:0-306-40615-2",
|
||||
expected: "0306406152",
|
||||
},
|
||||
{
|
||||
name: "Clean ISBN-13",
|
||||
input: "9783161484100",
|
||||
expected: "9783161484100",
|
||||
},
|
||||
{
|
||||
name: "Invalid identifier",
|
||||
input: "not-an-isbn",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := scanner.extractISBNFromIdentifier(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsValidUUID tests UUID validation
|
||||
func TestIsValidUUID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid UUID v4",
|
||||
input: "550e8400-e29b-41d4-a716-446655440000",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid UUID with uppercase",
|
||||
input: "550E8400-E29B-41D4-A716-446655440000",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid UUID - missing dashes",
|
||||
input: "550e8400e29b41d4a716446655440000",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid UUID - wrong format",
|
||||
input: "not-a-uuid",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty string",
|
||||
input: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isValidUUID(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v for input %s", tt.expected, result, tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermineHashConfidence tests confidence level determination
|
||||
func TestDetermineHashConfidence(t *testing.T) {
|
||||
scanner := &EbookScanner{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
identifier string
|
||||
expectedConf string
|
||||
}{
|
||||
{
|
||||
name: "High confidence - valid UUID",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
identifier: "",
|
||||
expectedConf: "high",
|
||||
},
|
||||
{
|
||||
name: "Medium confidence - ISBN",
|
||||
uuid: "",
|
||||
identifier: "isbn:978-3-16-148410-0",
|
||||
expectedConf: "medium",
|
||||
},
|
||||
{
|
||||
name: "Medium confidence - long identifier",
|
||||
uuid: "",
|
||||
identifier: "some-long-identifier-string",
|
||||
expectedConf: "medium",
|
||||
},
|
||||
{
|
||||
name: "Low confidence - no identifiers",
|
||||
uuid: "",
|
||||
identifier: "",
|
||||
expectedConf: "low",
|
||||
},
|
||||
{
|
||||
name: "Low confidence - short identifier",
|
||||
uuid: "",
|
||||
identifier: "abc",
|
||||
expectedConf: "low",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := scanner.determineHashConfidence(tt.uuid, tt.identifier)
|
||||
if result != tt.expectedConf {
|
||||
t.Errorf("Expected confidence %s, got %s", tt.expectedConf, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectFormatType tests format type detection
|
||||
func TestDetectFormatType(t *testing.T) {
|
||||
scanner := &EbookScanner{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
expectedType string
|
||||
}{
|
||||
{
|
||||
name: "EPUB file",
|
||||
filePath: "/path/to/book.epub",
|
||||
expectedType: "epub",
|
||||
},
|
||||
{
|
||||
name: "KEPUB file",
|
||||
filePath: "/path/to/book.kepub.epub",
|
||||
expectedType: "kepub",
|
||||
},
|
||||
{
|
||||
name: "PDF file",
|
||||
filePath: "/path/to/document.pdf",
|
||||
expectedType: "pdf",
|
||||
},
|
||||
{
|
||||
name: "CBZ file",
|
||||
filePath: "/path/to/comic.cbz",
|
||||
expectedType: "comic_archive",
|
||||
},
|
||||
{
|
||||
name: "MOBI file",
|
||||
filePath: "/path/to/book.mobi",
|
||||
expectedType: "mobi",
|
||||
},
|
||||
{
|
||||
name: "TXT file",
|
||||
filePath: "/path/to/book.txt",
|
||||
expectedType: "txt",
|
||||
},
|
||||
{
|
||||
name: "Unknown format",
|
||||
filePath: "/path/to/book.xyz",
|
||||
expectedType: "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := scanner.detectFormatType(tt.filePath)
|
||||
if result != tt.expectedType {
|
||||
t.Errorf("Expected format type %s, got %s", tt.expectedType, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractHashInfo tests the complete hash extraction flow
|
||||
func TestExtractHashInfo(t *testing.T) {
|
||||
scanner := &EbookScanner{}
|
||||
|
||||
// Create a temporary test file
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test_book.txt")
|
||||
testContent := "Test book content for hash extraction"
|
||||
|
||||
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test hash extraction
|
||||
hashInfo, formatInfo, err := scanner.extractHashInfo(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("extractHashInfo failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify hash info
|
||||
if hashInfo == nil {
|
||||
t.Fatal("hashInfo is nil")
|
||||
}
|
||||
|
||||
if hashInfo.FileSHA256 == "" {
|
||||
t.Error("FileSHA256 is empty")
|
||||
}
|
||||
|
||||
// Verify format info
|
||||
if formatInfo == nil {
|
||||
t.Fatal("formatInfo is nil")
|
||||
}
|
||||
|
||||
if formatInfo.FormatType != "txt" {
|
||||
t.Errorf("Expected format type 'txt', got %s", formatInfo.FormatType)
|
||||
}
|
||||
|
||||
if formatInfo.FileSHA256 != hashInfo.FileSHA256 {
|
||||
t.Error("FormatInfo.FileSHA256 doesn't match HashInfo.FileSHA256")
|
||||
}
|
||||
|
||||
t.Logf("Hash extraction test passed: SHA256=%s, Format=%s, Confidence=%s",
|
||||
hashInfo.FileSHA256, formatInfo.FormatType, hashInfo.HashConfidence)
|
||||
}
|
||||
|
||||
// BenchmarkCalculateFileSHA256 benchmarks SHA-256 calculation
|
||||
func BenchmarkCalculateFileSHA256(b *testing.B) {
|
||||
scanner := &EbookScanner{}
|
||||
|
||||
// Create a temporary test file
|
||||
tmpDir := b.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "bench_test.txt")
|
||||
testContent := strings.Repeat("Benchmark test content for SHA-256 calculation\n", 10000)
|
||||
|
||||
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
|
||||
b.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = scanner.calculateFileSHA256(testFile)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractHashInfoIntegration is an integration test that tests a realistic scenario
|
||||
func TestExtractHashInfoIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
scanner := &EbookScanner{}
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Test multiple file types
|
||||
testFiles := []struct {
|
||||
name string
|
||||
filename string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "Text file",
|
||||
filename: "test.txt",
|
||||
content: "Plain text file",
|
||||
},
|
||||
{
|
||||
name: "HTML file (simulating EPUB content)",
|
||||
filename: "test.html",
|
||||
content: "<html><body>Test content</body></html>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tf := range testFiles {
|
||||
t.Run(tf.name, func(t *testing.T) {
|
||||
testFile := filepath.Join(tmpDir, tf.filename)
|
||||
if err := os.WriteFile(testFile, []byte(tf.content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
hashInfo, formatInfo, err := scanner.extractHashInfo(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("extractHashInfo failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify SHA-256 is calculated
|
||||
if hashInfo.FileSHA256 == "" {
|
||||
t.Error("SHA-256 hash not calculated")
|
||||
}
|
||||
|
||||
// Verify format detection
|
||||
if formatInfo.FormatType == "unknown" {
|
||||
t.Logf("Warning: Format detected as 'unknown' for %s", tf.filename)
|
||||
}
|
||||
|
||||
t.Logf("Integration test passed for %s: SHA256=%s, Format=%s",
|
||||
tf.name, hashInfo.FileSHA256, formatInfo.FormatType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage
|
||||
func ExampleEbookScanner_calculateFileSHA256() {
|
||||
scanner := &EbookScanner{}
|
||||
|
||||
// Calculate SHA-256 hash of a file
|
||||
hash, err := scanner.calculateFileSHA256("/path/to/ebook.epub")
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("SHA-256: %s\n", hash)
|
||||
}
|
||||
Reference in New Issue
Block a user