diff --git a/README.md b/README.md index f1858ca..ef05578 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,13 @@ All endpoints return JSON. Base URL: `http://x.x.x.x:8192` { "spotter": "W1AW", "spotted": "K7RA", - "frequency": 14250000, + "frequency": 14250, "message": "CQ DX", - "when": "2025-01-01T10:00:00Z", + "when": "2025-01-01T10:00:00.000Z", "source": "dx.n9jr.com", "band": "20m", + "mode": "phone", + "submode": "USB", "dxcc_spotter": { "cont": "NA", "entity": "United States", @@ -92,8 +94,8 @@ All endpoints return JSON. Base URL: `http://x.x.x.x:8192` "cqz": "5", "ituz": "8", "lotw_user": false, - "lat": "39", - "lng": "-98" + "lat": "39.0", + "lng": "-98.0" }, "dxcc_spotted": { "cont": "NA", @@ -102,15 +104,21 @@ All endpoints return JSON. Base URL: `http://x.x.x.x:8192` "dxcc_id": "291", "cqz": "5", "ituz": "8", - "lotw_user": "2", - "lat": "39", - "lng": "-98" + "lotw_user": 2, + "lat": "39.0", + "lng": "-98.0" } } ] ``` -**Note:** `lotw_user` is `"2"` (string) if LoTW user, `false` (boolean) if not. +**Note:** +- `frequency` is in kHz (integer, e.g., 14250) +- `band` is calculated from frequency (e.g., "20m", "40m", "2m") +- `mode` and `submode` are available for POTA spots; empty strings for DX Cluster spots +- `cqz` (CQ Zone) and `ituz` (ITU Zone) are included in DXCC data +- `lotw_user` is the number of days since last LoTW upload (integer) if a LoTW member, `false` (boolean) if not +- `lat` and `lng` are strings with 1 decimal place precision ## Configuration diff --git a/backend/dxcc/client.go b/backend/dxcc/client.go index 5275fd8..4a7fb9e 100644 --- a/backend/dxcc/client.go +++ b/backend/dxcc/client.go @@ -1390,71 +1390,89 @@ func (c *Client) determineEffectivePrefix(callsign string) string { return prefix } -// buildDxccInfoFromPrefix constructs DxccInfo from a DxccPrefix. -func (c *Client) buildDxccInfoFromPrefix(pfx *DxccPrefix) *DxccInfo { +// buildDxccInfo is the centralized function for building DxccInfo objects. +// It ensures all fields including the flag are consistently populated. +// If applyTitleCase is true, entity names are converted to title case. +func buildDxccInfo(adif int, continent, entity string, cqz, ituz int, latitude, longitude float64, applyTitleCase bool) *DxccInfo { + entityName := entity + if applyTitleCase { + entityName = toUcWord(entity) + } info := &DxccInfo{ - Cont: pfx.Cont, - Entity: toUcWord(pfx.Entity), // Apply title case as in original Node.js - DXCCID: pfx.ADIF, - CQZ: pfx.CQZ, - Latitude: pfx.Lat, - Longitude: pfx.Long, + Cont: continent, + Entity: entityName, + DXCCID: adif, + CQZ: cqz, + ITUZ: ituz, + Latitude: latitude, + Longitude: longitude, + Flag: FlagEmojis[strconv.Itoa(adif)], } + return info +} +// buildDxccInfoFromPrefix constructs DxccInfo from a DxccPrefix. +func (c *Client) buildDxccInfoFromPrefix(pfx *DxccPrefix) *DxccInfo { // Prefer exported EntitiesMap if populated (tests may set it) and use it to enrich prefix info. entities := c.entitiesMap if len(c.EntitiesMap) != 0 { entities = c.EntitiesMap } - if entity, ok := entities[pfx.ADIF]; ok { + + // Start with data from prefix + entity := pfx.Entity + latitude := pfx.Lat + longitude := pfx.Long + cqz := pfx.CQZ + cont := pfx.Cont + ituz := 0 + + // Enrich with entity data if available + if ent, ok := entities[pfx.ADIF]; ok { // Use entity name for a canonical Entity value and entity lat/long when available - if entity.Name != "" { - info.Entity = toUcWord(entity.Name) + if ent.Name != "" { + entity = ent.Name } - if entity.Long != 0.0 { - info.Longitude = entity.Long + if ent.Long != 0.0 { + longitude = ent.Long } - if entity.Lat != 0.0 { - info.Latitude = entity.Lat + if ent.Lat != 0.0 { + latitude = ent.Lat } - info.ITUZ = entity.ITUZ + ituz = ent.ITUZ // Copy CQZ and Cont when available to match test expectations - if entity.CQZ != 0 { - info.CQZ = entity.CQZ + if ent.CQZ != 0 { + cqz = ent.CQZ } - if entity.Cont != "" { - info.Cont = entity.Cont + if ent.Cont != "" { + cont = ent.Cont } } - logging.Debug("DXCC build info from prefix ADIF=%d -> Entity=%q ITUZ=%d Lat=%f Lng=%f Flag=%q", pfx.ADIF, info.Entity, info.ITUZ, info.Latitude, info.Longitude, info.Flag) - - // Look up flag from static map - info.Flag = FlagEmojis[strconv.Itoa(pfx.ADIF)] + + // Use centralized function to build complete DxccInfo + info := buildDxccInfo(pfx.ADIF, cont, entity, cqz, ituz, latitude, longitude, true) + + logging.Debug("DXCC build info from prefix ADIF=%d -> Entity=%q ITUZ=%d Lat=%f Lng=%f Flag=%q", + info.DXCCID, info.Entity, info.ITUZ, info.Latitude, info.Longitude, info.Flag) + return info } // buildDxccInfoFromEntity constructs DxccInfo from a DxccEntity. Used for ADIF 0 (NONE). func (c *Client) buildDxccInfoFromEntity(entity *DxccEntity) *DxccInfo { - info := &DxccInfo{ - Cont: entity.Cont, - Entity: toUcWord(entity.Name), - DXCCID: entity.ADIF, - CQZ: entity.CQZ, - ITUZ: entity.ITUZ, - Latitude: entity.Lat, - Longitude: entity.Long, - } + entityName := entity.Name + // If ADIF 0, tests expect the specific '- None - (e.g. /MM, /AM)' entity name; normalize to that if entity.ADIF == 0 { // Accept several variants and normalize to test-expected capitalization/format name := strings.TrimSpace(entity.Name) if strings.EqualFold(name, "- none - (e.g. /mm, /am)") || strings.EqualFold(name, "- none -") || name == "" { - info.Entity = "- None - (e.g. /MM, /AM)" - } else { - info.Entity = toUcWord(name) + entityName = "- None - (e.g. /MM, /AM)" } } - info.Flag = FlagEmojis[strconv.Itoa(entity.ADIF)] // Should be empty for ADIF 0 + + // Use centralized function to build complete DxccInfo + info := buildDxccInfo(entity.ADIF, entity.Cont, entityName, entity.CQZ, entity.ITUZ, entity.Lat, entity.Long, true) return info } @@ -1528,12 +1546,16 @@ func (c *Client) GetException(call string) (*DxccInfo, bool) { if !found { return nil, false } - info := &DxccInfo{ - DXCCID: exc.ADIF, - Entity: exc.Entity, - CQZ: exc.CQZ, - Cont: exc.Cont, + + // Get ITUZ from entities map if available + ituz := 0 + if entity, ok := c.entitiesMap[exc.ADIF]; ok { + ituz = entity.ITUZ } + + // Build complete DxccInfo with all fields including flag + // Don't apply title case - use entity name as-is from database + info := buildDxccInfo(exc.ADIF, exc.Cont, exc.Entity, exc.CQZ, ituz, exc.Lat, exc.Long, false) return info, true } @@ -1546,11 +1568,15 @@ func (c *Client) GetPrefix(prefix string) (*DxccInfo, bool) { if !found { return nil, false } - info := &DxccInfo{ - DXCCID: pfx.ADIF, - Entity: pfx.Entity, - CQZ: pfx.CQZ, - Cont: pfx.Cont, + + // Get ITUZ from entities map if available + ituz := 0 + if entity, ok := c.entitiesMap[pfx.ADIF]; ok { + ituz = entity.ITUZ } + + // Build complete DxccInfo with all fields including flag + // Don't apply title case - use entity name as-is from database + info := buildDxccInfo(pfx.ADIF, pfx.Cont, pfx.Entity, pfx.CQZ, ituz, pfx.Lat, pfx.Long, false) return info, true } diff --git a/backend/lotw/client.go b/backend/lotw/client.go index 8c80fab..1a7c438 100644 --- a/backend/lotw/client.go +++ b/backend/lotw/client.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "regexp" "strings" "time" @@ -82,7 +83,7 @@ func NewClient(ctx context.Context, cfg config.Config, dbClient db.DBClient) (*C return client, nil } -// createTable creates the lotw_users table if it doesn't exist. +// createTable creates the lotw_users table if it doesn't exist, and handles schema migrations. func (c *Client) createTable() error { queries := []string{ fmt.Sprintf(` @@ -107,6 +108,98 @@ func (c *Client) createTable() error { return fmt.Errorf("failed to execute query: %w\nQuery: %s", err, query) } } + + // Handle schema migration: check if old schema with lotw_member column exists + if err := c.migrateSchema(db); err != nil { + return fmt.Errorf("failed to migrate schema: %w", err) + } + + return nil +} + +// migrateSchema handles schema migrations for existing databases. +// If the old schema with lotw_member column is detected, it will be removed +// since we now calculate days since upload dynamically. +func (c *Client) migrateSchema(db *sql.DB) error { + // Check if the lotw_member column exists in the table + query := fmt.Sprintf("PRAGMA table_info(%s);", dbTableName) + rows, err := db.Query(query) + if err != nil { + return fmt.Errorf("failed to check table schema: %w", err) + } + defer rows.Close() + + hasLoTWMemberColumn := false + for rows.Next() { + var cid int + var name string + var type_ string + var notnull int + var dfltValue interface{} + var pk int + + if err := rows.Scan(&cid, &name, &type_, ¬null, &dfltValue, &pk); err != nil { + return fmt.Errorf("failed to scan column info: %w", err) + } + + if name == "lotw_member" { + hasLoTWMemberColumn = true + break + } + } + + // If old schema exists, recreate the table without the lotw_member column + if hasLoTWMemberColumn { + logging.Info("Detected old LoTW schema with lotw_member column. Migrating to new schema...") + + // Create a temporary table with the new schema + tempTableName := dbTableName + "_new" + createTempQuery := fmt.Sprintf(` + CREATE TABLE %s ( + callsign TEXT PRIMARY KEY, + last_upload_utc TEXT NOT NULL + ); + `, tempTableName) + + if _, err := db.Exec(createTempQuery); err != nil { + return fmt.Errorf("failed to create temporary table: %w", err) + } + + // Copy data from old table to new table (only the columns that exist in new schema) + copyQuery := fmt.Sprintf(` + INSERT INTO %s (callsign, last_upload_utc) + SELECT callsign, last_upload_utc FROM %s; + `, tempTableName, dbTableName) + + if _, err := db.Exec(copyQuery); err != nil { + // Clean up temporary table + db.Exec(fmt.Sprintf("DROP TABLE %s;", tempTableName)) + return fmt.Errorf("failed to copy data to temporary table: %w", err) + } + + // Drop old table + dropQuery := fmt.Sprintf("DROP TABLE %s;", dbTableName) + if _, err := db.Exec(dropQuery); err != nil { + // Clean up temporary table + db.Exec(fmt.Sprintf("DROP TABLE %s;", tempTableName)) + return fmt.Errorf("failed to drop old table: %w", err) + } + + // Rename temporary table to original name + renameQuery := fmt.Sprintf("ALTER TABLE %s RENAME TO %s;", tempTableName, dbTableName) + if _, err := db.Exec(renameQuery); err != nil { + return fmt.Errorf("failed to rename table: %w", err) + } + + logging.Info("Successfully migrated LoTW schema to new version") + + // Clear the download metadata so the data will be re-fetched with the new schema + clearMetadataQuery := fmt.Sprintf("DELETE FROM %s WHERE data_type = ?;", metadataTableName) + if _, err := db.Exec(clearMetadataQuery, "lotw"); err != nil { + logging.Warn("Failed to clear LoTW metadata during migration: %v", err) + } + } + return nil } @@ -115,10 +208,10 @@ func (c *Client) updateLastDownloadTime(ctx context.Context, sourceURL string, f db := c.dbClient.GetDB() query := fmt.Sprintf(` INSERT OR REPLACE INTO %s (data_type, last_updated, file_size, source_url) - VALUES ('lotw', ?, ?, ?) + VALUES (?, ?, ?, ?) `, metadataTableName) - _, err := db.ExecContext(ctx, query, time.Now().UTC().Format(time.RFC3339), fileSize, sourceURL) + _, err := db.ExecContext(ctx, query, "lotw", time.Now().UTC().Format(time.RFC3339), fileSize, sourceURL) if err != nil { return fmt.Errorf("failed to update LoTW download metadata: %w", err) } @@ -129,11 +222,11 @@ func (c *Client) updateLastDownloadTime(ctx context.Context, sourceURL string, f func (c *Client) GetLastDownloadTime(ctx context.Context) (time.Time, error) { db := c.dbClient.GetDB() query := fmt.Sprintf(` - SELECT last_updated FROM %s WHERE data_type = 'lotw' + SELECT last_updated FROM %s WHERE data_type = ? `, metadataTableName) var lastUpdated string - err := db.QueryRowContext(ctx, query).Scan(&lastUpdated) + err := db.QueryRowContext(ctx, query, "lotw").Scan(&lastUpdated) if err != nil { if err == sql.ErrNoRows { // No record found, return zero time to indicate never downloaded @@ -184,7 +277,18 @@ func (c *Client) needsUpdate(ctx context.Context) (bool, error) { } // getTableRecordCount returns the number of records in the specified table. +// Only allows whitelisted table names to prevent SQL injection. func (c *Client) getTableRecordCount(ctx context.Context, tableName string) (int, error) { + // Whitelist of allowed table names to prevent SQL injection via table name parameter + allowedTables := map[string]bool{ + dbTableName: true, + metadataTableName: true, + } + + if !allowedTables[tableName] { + return 0, fmt.Errorf("invalid table name: %s", tableName) + } + var count int query := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableName) err := c.dbClient.GetDB().QueryRowContext(ctx, query).Scan(&count) @@ -304,6 +408,15 @@ func (c *Client) fetchAndStoreUsers(ctx context.Context) error { return nil } +// isValidCallsign validates that a callsign contains only alphanumeric characters and slashes. +// This prevents SQL injection by ensuring only safe characters are in the callsign field. +func isValidCallsign(callsign string) bool { + // Amateur radio callsigns contain letters, numbers, and slashes only + // Examples: W1AW, N0CALL, VE3/W1AW, KH0/W1AW + validCallsignPattern := regexp.MustCompile(`^[A-Z0-9/]+$`) + return len(callsign) > 0 && len(callsign) <= 20 && validCallsignPattern.MatchString(callsign) +} + // parseLoTWCSV parses the LoTW CSV data from an io.Reader. func parseLoTWCSV(r io.Reader) ([]UserActivity, error) { scanner := bufio.NewScanner(r) @@ -320,6 +433,12 @@ func parseLoTWCSV(r io.Reader) ([]UserActivity, error) { dateStr := strings.TrimSpace(parts[1]) timeStr := strings.TrimSpace(parts[2]) + // Validate callsign to prevent SQL injection + if !isValidCallsign(callsign) { + logging.Warn("Skipping LoTW entry with invalid callsign: %q", callsign) + continue + } + // LoTW dates are UTC YYYY-MM-DD, times are UTC HH:MM:SS // We'll combine them and parse as UTC. combinedDateTimeStr := fmt.Sprintf("%s %s UTC", dateStr, timeStr) @@ -402,7 +521,16 @@ func (c *Client) replaceUsersInDB(users []UserActivity) (err error) { defer stmt.Close() // Close the statement after loop, before commit for _, user := range users { + // Validate callsign is safe before database insertion + // Input has already been validated by isValidCallsign() during CSV parsing, + // but we validate again here to ensure defense-in-depth and satisfy security scanners. + if !isValidCallsign(user.Callsign) { + logging.Warn("Skipping invalid callsign during database insert: %q", user.Callsign) + continue + } + // Store as ISO 8601 string for consistency and easy parsing back to time.Time + // Callsign is now proven safe; timestamp is generated internally (not from user input) _, err = stmt.Exec(user.Callsign, user.LastUploadUTC.Format(time.RFC3339)) if err != nil { return fmt.Errorf("failed to insert LoTW user %s: %w", user.Callsign, err) diff --git a/backend/pota/client.go b/backend/pota/client.go index ea438bd..cb5c36e 100644 --- a/backend/pota/client.go +++ b/backend/pota/client.go @@ -465,6 +465,8 @@ func (c *Client) fetchAndProcessSpots(ctx context.Context) { Message: potaSpot.Message, When: potaSpot.When, Source: potaSpot.Source, + Mode: item.Mode, + Submode: "", // POTA doesn't provide submode } unified.AdditionalData.PotaRef = potaSpot.AdditionalData.PotaRef unified.AdditionalData.PotaMode = potaSpot.AdditionalData.PotaMode diff --git a/main.go b/main.go index e08af4e..34c8ed3 100644 --- a/main.go +++ b/main.go @@ -426,6 +426,9 @@ func forwardClusterSpots(ctx context.Context, client *cluster.Client, out chan<- Message: s.Message, When: s.When, Source: s.Source, + Band: "", // Will be calculated downstream + Mode: "", // Not available from cluster + Submode: "", // Not available from cluster }: logging.Info("CLUSTER [%s] FORWARDED SPOT #%d to aggregator", clusterHost, forwardedCount) case <-ctx.Done(): diff --git a/spot/spot.go b/spot/spot.go index 93d9dc5..555f485 100644 --- a/spot/spot.go +++ b/spot/spot.go @@ -7,6 +7,7 @@ import ( "github.com/user00265/dxclustergoapi/backend/dxcc" "github.com/user00265/dxclustergoapi/backend/lotw" + "github.com/user00265/dxclustergoapi/utils" ) // Info represents the enriched DXCC and LoTW information for a callsign. @@ -26,11 +27,13 @@ type Spot struct { When time.Time `json:"when"` Source string `json:"source"` // e.g., "DXCluster", "SOTA", "pota" Band string `json:"band"` // e.g., "20m", "40m" + Mode string `json:"mode"` // e.g., "phone", "cw", "data" + Submode string `json:"submode"` // e.g., "USB", "LSB", "AM" SpotterInfo Info `json:"spotter_data"` SpottedInfo Info `json:"spotted_data"` - // Additional data for POTA, if applicable + // Additional data for various activations and references AdditionalData struct { PotaRef string `json:"pota_ref,omitempty"` PotaMode string `json:"pota_mode,omitempty"` @@ -39,16 +42,17 @@ type Spot struct { // MarshalJSON customizes the JSON output to match the expected API format func (s Spot) MarshalJSON() ([]byte, error) { - // Helper to build the flattened DXCC+LoTW+POTA object + // Helper to build the flattened DXCC+LoTW object with optional POTA data type FlatInfo struct { Cont string `json:"cont,omitempty"` Entity string `json:"entity,omitempty"` Flag string `json:"flag,omitempty"` DXCCID interface{} `json:"dxcc_id,omitempty"` // string in output + CQZ interface{} `json:"cqz,omitempty"` // string in output - CQ Zone + ITUZ interface{} `json:"ituz,omitempty"` // string in output - ITU Zone LoTWUser interface{} `json:"lotw_user"` // "2" or false Lat interface{} `json:"lat,omitempty"` // string in output Lng interface{} `json:"lng,omitempty"` // string in output - CQZ interface{} `json:"cqz,omitempty"` // string in output - CQ Zone PotaRef string `json:"pota_ref,omitempty"` PotaMode string `json:"pota_mode,omitempty"` } @@ -66,22 +70,22 @@ func (s Spot) MarshalJSON() ([]byte, error) { if info.DXCC.DXCCID != 0 { flat.DXCCID = fmt.Sprintf("%d", info.DXCC.DXCCID) } + if info.DXCC.CQZ != 0 { + flat.CQZ = fmt.Sprintf("%d", info.DXCC.CQZ) + } + if info.DXCC.ITUZ != 0 { + flat.ITUZ = fmt.Sprintf("%d", info.DXCC.ITUZ) + } if info.DXCC.Latitude != 0 { flat.Lat = fmt.Sprintf("%.1f", info.DXCC.Latitude) } if info.DXCC.Longitude != 0 { flat.Lng = fmt.Sprintf("%.1f", info.DXCC.Longitude) } - if info.DXCC.CQZ != 0 { - flat.CQZ = fmt.Sprintf("%d", info.DXCC.CQZ) - } - } - // LoTW user: "2" if user, false if not - if info.IsLoTWUser && info.LoTW != nil { - flat.LoTWUser = "2" // Indicates LoTW user with upload - } else { - flat.LoTWUser = false } + // LoTW user: number of days since last upload (matching WaveLog API behavior) + // This will be an integer like 2, 31, etc., or false if not a LoTW member + flat.LoTWUser = utils.GetLoTWMemberValue(info.LoTW) // Add POTA information flat.PotaRef = additionalData.PotaRef flat.PotaMode = additionalData.PotaMode @@ -99,28 +103,32 @@ func (s Spot) MarshalJSON() ([]byte, error) { } // Build the output structure - single variables first, then objects - // Single variables: spotter, spotted, frequency, band, message, when, source - // Objects: dxcc_spotter, dxcc_spotted (contain pota_ref and pota_mode) + // Single variables: spotter, spotted, frequency, band, mode, submode, message, when, source + // Objects: dxcc_spotter, dxcc_spotted (with optional POTA data) // Frequency output is integer kHz (stored internally as Hz) // When is formatted with millisecond precision (RFC3339 with 3 decimal places) output := struct { Spotter string `json:"spotter"` Spotted string `json:"spotted"` Frequency int `json:"frequency"` // Integer kHz output (e.g., 14272) - Band string `json:"band"` Message string `json:"message"` When string `json:"when"` Source string `json:"source,omitempty"` + Band string `json:"band"` + Mode string `json:"mode,omitempty"` + Submode string `json:"submode,omitempty"` DXCCSpotter *FlatInfo `json:"dxcc_spotter,omitempty"` DXCCSpotted *FlatInfo `json:"dxcc_spotted,omitempty"` }{ Spotter: s.Spotter, Spotted: s.Spotted, Frequency: int(s.Frequency / 1000), // Convert Hz to kHz integer (14250000 Hz -> 14250) - Band: s.Band, Message: s.Message, When: s.When.UTC().Format("2006-01-02T15:04:05.000Z07:00"), // Millisecond precision Source: s.Source, + Band: s.Band, + Mode: s.Mode, + Submode: s.Submode, DXCCSpotter: dxccSpotter, DXCCSpotted: dxccSpotted, } diff --git a/utils/dxcc_lookup.go b/utils/dxcc_lookup.go index 22e6f19..05fe132 100644 --- a/utils/dxcc_lookup.go +++ b/utils/dxcc_lookup.go @@ -2,6 +2,7 @@ package utils import ( "regexp" + "strconv" "strings" "github.com/user00265/dxclustergoapi/backend/dxcc" @@ -13,6 +14,35 @@ type DXCCLookupClient interface { GetPrefix(prefix string) (*dxcc.DxccInfo, bool) } +// BuildDxccInfo constructs a complete DxccInfo object from basic DXCC data. +// This is the single source of truth for DXCC output formatting. +// Entity names are converted to title case for consistent output. +func BuildDxccInfo(adif int, continent, entity string, cqz, ituz int, latitude, longitude float64) *dxcc.DxccInfo { + info := &dxcc.DxccInfo{ + Cont: continent, + Entity: toUcWord(entity), + DXCCID: adif, + CQZ: cqz, + ITUZ: ituz, + Latitude: latitude, + Longitude: longitude, + Flag: dxcc.FlagEmojis[strconv.Itoa(adif)], + } + return info +} + +// toUcWord converts a string to title case (capitalize each word). +// This mirrors the behavior of the backend dxcc package for consistency. +func toUcWord(s string) string { + words := strings.Fields(strings.ToLower(s)) + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(word[:1]) + word[1:] + } + } + return strings.Join(words, " ") +} + // LookupDXCC performs DXCC entity lookup for a callsign. func LookupDXCC(call string, client DXCCLookupClient) (*dxcc.DxccInfo, error) { call = strings.ToUpper(strings.TrimSpace(call)) diff --git a/utils/dxcc_lookup_test.go b/utils/dxcc_lookup_test.go index 5c33f97..00aa388 100644 --- a/utils/dxcc_lookup_test.go +++ b/utils/dxcc_lookup_test.go @@ -174,6 +174,169 @@ func TestLookupDXCC_WithRealData(t *testing.T) { } } +// TestLookupDXCC_FlagField verifies that flag field is populated from all code paths +func TestLookupDXCC_FlagField(t *testing.T) { + // Create a mock client with real DXCC client to test actual flag lookup + ctx := context.Background() + + // Create DB client (temporary for this test) + cfg := config.Config{DXCCUpdateInterval: 0} + dataDir := "." // Uses current directory for dxcc.db + dbClient, err := db.NewSQLiteClient(dataDir, dxcc.DBFileName) + if err != nil { + t.Fatalf("Failed to create SQLite DB client: %v", err) + } + defer dbClient.Close() + + // Create DXCC client + dxccClient, err := dxcc.NewClient(ctx, cfg, dbClient) + if err != nil { + t.Fatalf("Failed to create DXCC client: %v", err) + } + defer dxccClient.Close() + + // Load data if needed + if err := dxccClient.LoadMapsFromDB(ctx); err != nil { + t.Fatalf("Failed to load DXCC maps from database: %v", err) + } + + // Check if data is loaded + if len(dxccClient.PrefixesMap) == 0 { + t.Skip("DXCC data not available; skipping flag test") + } + + // Test cases with known ADIF numbers that have flags + testCases := []struct { + callsign string + expectedFlag string + description string + }{ + {"K1JT", "πŸ‡ΊπŸ‡Έ", "USA via prefix lookup"}, + {"W2ABC", "πŸ‡ΊπŸ‡Έ", "USA via prefix lookup"}, + {"VE3ABC", "πŸ‡¨πŸ‡¦", "Canada via prefix lookup"}, + {"G3ABC", "πŸ‡¬πŸ‡§", "UK via prefix lookup"}, + {"JA1ABC", "πŸ‡―πŸ‡΅", "Japan via prefix lookup"}, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + result, err := LookupDXCC(tc.callsign, dxccClient) + if err != nil { + t.Errorf("LookupDXCC(%q) error: %v", tc.callsign, err) + return + } + if result == nil { + t.Errorf("LookupDXCC(%q) returned nil", tc.callsign) + return + } + if result.Flag == "" { + t.Errorf("LookupDXCC(%q).Flag is empty, expected %q", tc.callsign, tc.expectedFlag) + } + if result.Flag != tc.expectedFlag { + t.Logf("LookupDXCC(%q).Flag = %q, expected %q (may be OK if entity mapping changed)", tc.callsign, result.Flag, tc.expectedFlag) + } + }) + } +} + +// TestBuildDxccInfo verifies the centralized BuildDxccInfo function +func TestBuildDxccInfo(t *testing.T) { + testCases := []struct { + name string + adif int + continent string + entity string + cqz int + ituz int + latitude float64 + longitude float64 + expectedFlag string + }{ + { + name: "United States", + adif: 291, + continent: "NA", + entity: "UNITED STATES OF AMERICA", + cqz: 5, + ituz: 8, + latitude: 39.0, + longitude: -98.0, + expectedFlag: "πŸ‡ΊπŸ‡Έ", + }, + { + name: "Canada", + adif: 1, + continent: "NA", + entity: "CANADA", + cqz: 5, + ituz: 9, + latitude: 45.0, + longitude: -75.0, + expectedFlag: "πŸ‡¨πŸ‡¦", + }, + { + name: "Japan", + adif: 339, + continent: "AS", + entity: "JAPAN", + cqz: 25, + ituz: 45, + latitude: 35.0, + longitude: 139.0, + expectedFlag: "πŸ‡―πŸ‡΅", + }, + { + name: "Unknown entity (no flag)", + adif: 9999, + continent: "XX", + entity: "TEST ENTITY", + cqz: 0, + ituz: 0, + latitude: 0.0, + longitude: 0.0, + expectedFlag: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := BuildDxccInfo(tc.adif, tc.continent, tc.entity, tc.cqz, tc.ituz, tc.latitude, tc.longitude) + + if result == nil { + t.Fatal("BuildDxccInfo returned nil") + } + + if result.DXCCID != tc.adif { + t.Errorf("DXCCID = %d, want %d", result.DXCCID, tc.adif) + } + if result.Cont != tc.continent { + t.Errorf("Cont = %q, want %q", result.Cont, tc.continent) + } + if result.CQZ != tc.cqz { + t.Errorf("CQZ = %d, want %d", result.CQZ, tc.cqz) + } + if result.ITUZ != tc.ituz { + t.Errorf("ITUZ = %d, want %d", result.ITUZ, tc.ituz) + } + if result.Latitude != tc.latitude { + t.Errorf("Latitude = %f, want %f", result.Latitude, tc.latitude) + } + if result.Longitude != tc.longitude { + t.Errorf("Longitude = %f, want %f", result.Longitude, tc.longitude) + } + if result.Flag != tc.expectedFlag { + t.Errorf("Flag = %q, want %q", result.Flag, tc.expectedFlag) + } + + // Verify entity name has title case applied + expectedEntity := toUcWord(tc.entity) + if result.Entity != expectedEntity { + t.Errorf("Entity = %q, want %q (title case)", result.Entity, expectedEntity) + } + }) + } +} + // mockDXCCClient is a mock implementation for unit testing type mockDXCCClient struct { exceptions map[string]*dxcc.DxccInfo diff --git a/utils/lotw.go b/utils/lotw.go new file mode 100644 index 0000000..136cf19 --- /dev/null +++ b/utils/lotw.go @@ -0,0 +1,24 @@ +package utils + +import ( + "time" + + "github.com/user00265/dxclustergoapi/backend/lotw" +) + +// GetLoTWMemberValue calculates the number of days since last LoTW upload. +// This matches the WaveLog API behavior which returns: +// - The number of days since last upload (e.g., "2", "31", etc.) if the callsign was found +// - false if the callsign was not found in the LoTW database +func GetLoTWMemberValue(lotwActivity *lotw.UserActivity) interface{} { + if lotwActivity == nil { + return false + } + + // Calculate days since last upload + now := time.Now().UTC() + daysSinceUpload := int(now.Sub(lotwActivity.LastUploadUTC).Hours() / 24) + + // Return the number of days as the value (matching WaveLog API behavior) + return daysSinceUpload +}