diff --git a/.github/workflows/daily-partial-update.yml b/.github/workflows/daily-partial-update.yml new file mode 100644 index 0000000..da80b18 --- /dev/null +++ b/.github/workflows/daily-partial-update.yml @@ -0,0 +1,35 @@ +name: Daily Partial Data Update + +on: + schedule: + # Run partial updates Wednesday through Sunday at midnight UTC + # https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows + - cron: "0 0 * * 3-7" + workflow_dispatch: + +jobs: + partial-update: + runs-on: ubuntu-latest + steps: + - name: Download latest hamcall binary + run: | + # Get the latest release + LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${{ github.repository }}/releases" | jq -r 'map(select(.draft == false)) | sort_by(.published_at) | last | .tag_name') + echo "Latest release: $LATEST_RELEASE" + + # Download the binary + curl -L -o hamcall "https://github.com/${{ github.repository }}/releases/download/$LATEST_RELEASE/hamcall" + chmod +x hamcall + + - name: Run hamcall in partial mode + id: run_hamcall_partial + run: ./hamcall -dl -m=b2 + env: + B2_KEYID: ${{ secrets.B2_KEYID }} + B2_APPKEY: ${{ secrets.B2_APPKEY }} + GEO_URL: ${{ secrets.GEO_URL }} + ULS_MODE: partial + + - name: Send workflow notification + run: | + curl -X POST --data-urlencode "payload={\"username\": \"Github Actions\", \"icon_url\": \"https://camo.githubusercontent.com/eeb25c8fe7d597494949032a0a1b2e79830bd8c69d5cc495efdb62fac04007c0/68747470733a2f2f63646e2e73766172756e2e6465762f67682f616374696f6e732e706e67\", \"attachments\": [{\"author_name\": \"hamcall.dev\", \"title\": \"daily partial update result: ${{ job.status }}\", \"title_link\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\", \"text\": \"Partial update completed - Updated ${{steps.run_hamcall_partial.outputs.updated-records}} records in ${{steps.run_hamcall_partial.outputs.run-time}}\"}]}" ${{ secrets.DISCORD_WEBHOOK }} diff --git a/.github/workflows/run-process.yml b/.github/workflows/run-process.yml index 923b16e..b6c7513 100644 --- a/.github/workflows/run-process.yml +++ b/.github/workflows/run-process.yml @@ -1,13 +1,14 @@ -name: Weekly Data Update +name: Weekly Full Data Update on: schedule: + # Run full update every Monday at midnight UTC # https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows - cron: "0 0 * * 1" workflow_dispatch: jobs: - update: + full-update: runs-on: ubuntu-latest steps: - name: Download latest hamcall binary @@ -30,4 +31,4 @@ jobs: - name: Send workflow notification run: | - curl -X POST --data-urlencode "payload={\"username\": \"Github Actions\", \"icon_url\": \"https://camo.githubusercontent.com/eeb25c8fe7d597494949032a0a1b2e79830bd8c69d5cc495efdb62fac04007c0/68747470733a2f2f63646e2e73766172756e2e6465762f67682f616374696f6e732e706e67\", \"attachments\": [{\"author_name\": \"hamcall.dev\", \"title\": \"build result: ${{ job.status }}\", \"title_link\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\", \"text\": \"Updated ${{steps.run_hamcall.outputs.updated-records}} records in ${{steps.run_hamcall.outputs.run-time}}\"}]}" ${{ secrets.DISCORD_WEBHOOK }} + curl -X POST --data-urlencode "payload={\"username\": \"Github Actions\", \"icon_url\": \"https://camo.githubusercontent.com/eeb25c8fe7d597494949032a0a1b2e79830bd8c69d5cc495efdb62fac04007c0/68747470733a2f2f63646e2e73766172756e2e6465762f67682f616374696f6e732e706e67\", \"attachments\": [{\"author_name\": \"hamcall.dev\", \"title\": \"weekly full update result: ${{ job.status }}\", \"title_link\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\", \"text\": \"Full weekly update completed - Updated ${{steps.run_hamcall.outputs.updated-records}} records in ${{steps.run_hamcall.outputs.run-time}}\"}]}" ${{ secrets.DISCORD_WEBHOOK }} diff --git a/downloader/downloader_test.go b/downloader/downloader_test.go index 94450eb..fd40616 100644 --- a/downloader/downloader_test.go +++ b/downloader/downloader_test.go @@ -66,8 +66,8 @@ func TestFetchHttpInvalidURL(t *testing.T) { os.Chdir(tempDir) defer os.Chdir(originalDir) - // Test with invalid URL - err = FetchHttp("test.txt", "http://invalid-url-that-does-not-exist.com") + // Test with invalid URL - use an invalid scheme and malformed URL + err = FetchHttp("test.txt", "://invalid-malformed-url") if err == nil { t.Fatalf("Expected error for invalid URL, got nil") } @@ -244,4 +244,4 @@ func TestUnzipZipSlipProtection(t *testing.T) { if !strings.Contains(err.Error(), "illegal file path") { t.Fatalf("Expected 'illegal file path' error, got: %v", err) } -} \ No newline at end of file +} diff --git a/main.go b/main.go index d8b7e8d..32b669c 100644 --- a/main.go +++ b/main.go @@ -99,11 +99,14 @@ func downloadFiles() { } func process(calls *map[string]data.HamCall) { + ulsMode := os.Getenv("ULS_MODE") + partialMode := ulsMode == "partial" + uls.Process(calls) ised.Process(calls, "ised_data/amateur_delim.txt") - radioid.Process(calls) - lotw.Process(calls) - geo.Process(calls) + radioid.Process(calls, partialMode) + lotw.Process(calls, partialMode) + geo.Process(calls, partialMode) } func writeToB2(calls *map[string]data.HamCall, keyID, applicationKey string, uploadWorkers int, osSigExit chan bool, dryRun bool) { diff --git a/source/geo/geo.go b/source/geo/geo.go index acdb3f5..15b5f9b 100644 --- a/source/geo/geo.go +++ b/source/geo/geo.go @@ -24,7 +24,7 @@ func Download(wg *sync.WaitGroup) error { return nil } -func Process(calls *map[string]data.HamCall) { +func Process(calls *map[string]data.HamCall, partialMode bool) { start := time.Now() fmt.Print("processing GEO") @@ -65,16 +65,18 @@ func Process(calls *map[string]data.HamCall) { call := record[0] item, c := (*calls)[call] if c { + // Update existing callsign item.Location = &loc - } else { + (*calls)[call] = item + } else if !partialMode { + // Only create new callsign if not in partial mode item = data.HamCall{ Callsign: call, Location: &loc, } + (*calls)[call] = item } - (*calls)[call] = item - + // In partial mode, skip callsigns that don't exist in ULS data } fmt.Printf(" ... %s\n", time.Since(start).String()) - } diff --git a/source/geo/geo_test.go b/source/geo/geo_test.go index 268af08..012c4f5 100644 --- a/source/geo/geo_test.go +++ b/source/geo/geo_test.go @@ -41,7 +41,7 @@ N0DEF,Bob Wilson,Bob,Wilson,789 Pine Rd,Nowhere,FL,98765,US,25.5,-80.25,EL95ab` // Test processing calls := make(map[string]data.HamCall) - Process(&calls) + Process(&calls, false) // Verify results - should be 3 calls (header is skipped due to invalid coordinates) if len(calls) != 3 { @@ -141,7 +141,7 @@ W5TEST,John Smith,John,Smith,123 Main St,Anytown,TX,12345,US,32.5,-97.0,EM12ab` } // Test processing - Process(&calls) + Process(&calls, false) // Verify results - should have 1 call (header skipped due to invalid coordinates) if len(calls) != 1 { @@ -204,7 +204,7 @@ N0DEF,Bob Wilson,Bob,Wilson,789 Pine Rd,Nowhere,FL,98765,US,25.5,-80.25,EL95ab` // Test processing calls := make(map[string]data.HamCall) - Process(&calls) + Process(&calls, false) // Should only process valid records - only N0DEF (header, W5TEST and KC5ABC have invalid coords) if len(calls) != 1 { @@ -247,7 +247,7 @@ func TestGeoProcessMissingFile(t *testing.T) { calls := make(map[string]data.HamCall) // This should not panic or error, just return without processing - Process(&calls) + Process(&calls, false) // Should have no calls since file doesn't exist if len(calls) != 0 { diff --git a/source/lotw/lotw.go b/source/lotw/lotw.go index 4522404..2be79e1 100644 --- a/source/lotw/lotw.go +++ b/source/lotw/lotw.go @@ -23,7 +23,7 @@ func Download(wg *sync.WaitGroup) error { return nil } -func Process(calls *map[string]data.HamCall) { +func Process(calls *map[string]data.HamCall, partialMode bool) { start := time.Now() fmt.Print("processing LOTW") @@ -49,16 +49,19 @@ func Process(calls *map[string]data.HamCall) { call := record[0] item, c := (*calls)[call] if c { + // Update existing callsign item.LOTW = record[1] + record[2] - } else { + (*calls)[call] = item + } else if !partialMode { + // Only create new callsign if not in partial mode item = data.HamCall{ Callsign: call, LOTW: record[1] + record[2], } + (*calls)[call] = item } - (*calls)[call] = item + // In partial mode, skip callsigns that don't exist in ULS data } fmt.Printf(" ... %s\n", time.Since(start).String()) - } diff --git a/source/lotw/lotw_test.go b/source/lotw/lotw_test.go index 702da02..6bf25e7 100644 --- a/source/lotw/lotw_test.go +++ b/source/lotw/lotw_test.go @@ -41,7 +41,7 @@ N0DEF,2023-02-28,2023-02-28` // Test processing calls := make(map[string]data.HamCall) - Process(&calls) + Process(&calls, false) // Verify results - should be 4 calls (3 real + 1 header) if len(calls) != 4 { @@ -126,7 +126,7 @@ W5TEST,2023-01-15,2023-01-15` } // Test processing - Process(&calls) + Process(&calls, false) // Verify results - should have 2 calls (1 existing + 1 header) if len(calls) != 2 { @@ -162,7 +162,7 @@ func TestLotwProcessMissingFile(t *testing.T) { calls := make(map[string]data.HamCall) // This should not panic or error, just return without processing - Process(&calls) + Process(&calls, false) // Should have no calls since file doesn't exist if len(calls) != 0 { diff --git a/source/radioid/radioid.go b/source/radioid/radioid.go index a8e40bc..8d4ce11 100644 --- a/source/radioid/radioid.go +++ b/source/radioid/radioid.go @@ -24,7 +24,7 @@ func Download(wg *sync.WaitGroup) error { return nil } -func Process(calls *map[string]data.HamCall) { +func Process(calls *map[string]data.HamCall, partialMode bool) { start := time.Now() fmt.Print("processing radioID") @@ -56,15 +56,18 @@ func Process(calls *map[string]data.HamCall) { call := record[1] item, c := (*calls)[call] if c { + // Update existing callsign item.DMRID = append(item.DMRID, id) - } else { + (*calls)[call] = item + } else if !partialMode { + // Only create new callsign if not in partial mode item = data.HamCall{ Callsign: call, DMRID: []int{id}, } - + (*calls)[call] = item } - (*calls)[call] = item + // In partial mode, skip callsigns that don't exist in ULS data } fmt.Printf(" ... %s\n", time.Since(start).String()) } diff --git a/source/radioid/radioid_test.go b/source/radioid/radioid_test.go index bbf5743..ec060bf 100644 --- a/source/radioid/radioid_test.go +++ b/source/radioid/radioid_test.go @@ -40,7 +40,7 @@ func TestRadioIdProcess(t *testing.T) { // Test processing calls := make(map[string]data.HamCall) - Process(&calls) + Process(&calls, false) // Verify results if len(calls) != 3 { @@ -126,7 +126,7 @@ func TestRadioIdProcessWithExistingCalls(t *testing.T) { } // Test processing - Process(&calls) + Process(&calls, false) // Verify results - should have 1 call with multiple DMRIDs if len(calls) != 1 { @@ -189,7 +189,7 @@ func TestRadioIdProcessInvalidID(t *testing.T) { // Test processing calls := make(map[string]data.HamCall) - Process(&calls) + Process(&calls, false) // Should only process the valid record if len(calls) != 1 { @@ -224,7 +224,7 @@ func TestRadioIdProcessMissingFile(t *testing.T) { calls := make(map[string]data.HamCall) // This should not panic or error, just return without processing - Process(&calls) + Process(&calls, false) // Should have no calls since file doesn't exist if len(calls) != 0 { diff --git a/source/uls/uls.go b/source/uls/uls.go index c53b317..e3bfa18 100644 --- a/source/uls/uls.go +++ b/source/uls/uls.go @@ -18,13 +18,34 @@ import ( func Download(wg *sync.WaitGroup) error { defer wg.Done() - // Daily files for future reference - // https: //data.fcc.gov/download/pub/uls/daily/a_am_sun.zip - // https: //data.fcc.gov/download/pub/uls/daily/l_am_sun.zip - - wg.Add(2) - go DownloadLicenses(wg) - go DownloadApplications(wg) + // Check the ULS processing mode + ulsMode := os.Getenv("ULS_MODE") + + switch ulsMode { + case "partial": + wg.Add(2) + go DownloadDailyLicenses(wg) + go DownloadDailyApplications(wg) + case "full": + // First download weekly files, then daily files to avoid overwriting merged data + weeklyWg := &sync.WaitGroup{} + weeklyWg.Add(2) + go DownloadLicenses(weeklyWg) + go DownloadApplications(weeklyWg) + + // Wait for weekly downloads to complete before starting daily downloads + weeklyWg.Wait() + + // Now download and merge daily files + wg.Add(2) + go DownloadDailyLicenses(wg) + go DownloadDailyApplications(wg) + default: + // Default to weekly processing + wg.Add(2) + go DownloadLicenses(wg) + go DownloadApplications(wg) + } return nil } @@ -69,6 +90,239 @@ func DownloadApplications(wg *sync.WaitGroup) error { return nil } +// getPreviousBusinessDay returns the day code for the most recent business day with daily files +// Daily files are published the day after, so Monday's file is available on Tuesday after noon +func getPreviousBusinessDay() string { + now := time.Now() + + // Get yesterday's day + yesterday := now.AddDate(0, 0, -1) + dayOfWeek := yesterday.Weekday() + + // Handle weekends - if yesterday was Sunday or Saturday, get Friday + switch dayOfWeek { + case time.Sunday: + // Yesterday was Sunday, get Friday's file (2 days back) + yesterday = yesterday.AddDate(0, 0, -2) + case time.Saturday: + // Yesterday was Saturday, get Friday's file (1 day back) + yesterday = yesterday.AddDate(0, 0, -1) + } + + // Convert to 3-letter lowercase day code + day := yesterday.Format("Mon") + return strings.ToLower(day[:3]) +} + +// getAllDailysSinceWeekly returns all day codes for daily files since the last weekly +// Weekly files are published on Sunday, so get Monday through the previous business day +func getAllDailysSinceWeekly() []string { + var days []string + now := time.Now() + + // Find the most recent Sunday (when weekly was published) + daysBack := int(now.Weekday()) + if daysBack == 0 { + daysBack = 7 // If today is Sunday, go back to previous Sunday + } + lastSunday := now.AddDate(0, 0, -daysBack) + + // Collect all business days from Monday after last Sunday to yesterday + for d := lastSunday.AddDate(0, 0, 1); d.Before(now); d = d.AddDate(0, 0, 1) { + if d.Weekday() != time.Saturday && d.Weekday() != time.Sunday { + dayStr := d.Format("Mon") + days = append(days, strings.ToLower(dayStr[:3])) + } + } + + return days +} +func DownloadDailyLicenses(wg *sync.WaitGroup) error { + defer wg.Done() + + ulsMode := os.Getenv("ULS_MODE") + var daysToDownload []string + + if ulsMode == "full" { + fmt.Println("Downloading ULS Daily License data (full mode - all dailies since weekly)") + daysToDownload = getAllDailysSinceWeekly() + if len(daysToDownload) == 0 { + fmt.Println("No daily files to download in full mode") + return nil + } + } else { + fmt.Println("Downloading ULS Daily License data (partial mode)") + day := getPreviousBusinessDay() + daysToDownload = []string{day} + } + + for _, day := range daysToDownload { + dailyUrl := fmt.Sprintf("ftp://wirelessftp.fcc.gov:21/pub/uls/daily/l_am_%s.zip", day) + dailyFileName := fmt.Sprintf("l_am_%s.zip", day) + + fmt.Printf("Downloading daily license file for %s...\n", day) + err := downloader.FetchFtp(dailyFileName, dailyUrl) + if err != nil { + fmt.Printf("Failed to download daily license file for %s, skipping: %v\n", day, err) + continue + } + + // For full mode, extract to separate directory per day, then merge + extractDir := "l_amat" + if ulsMode == "full" { + extractDir = fmt.Sprintf("l_amat_daily_%s", day) + } + + files, err := downloader.Unzip(dailyFileName, extractDir) + if err != nil { + fmt.Printf("Failed to unzip daily license file for %s, skipping: %v\n", day, err) + continue + } + + fmt.Printf("Daily License files for %s unzipped:\n%s\n", day, strings.Join(files, "\n")) + } + + // For partial mode, check if daily files contain essential data and fallback if needed + if ulsMode == "partial" { + if !dailyFilesContainEssentialData("l_amat") { + fmt.Println("Daily files missing essential data, downloading weekly files...") + wg.Add(1) + return DownloadLicenses(wg) + } + } + + // For full mode, merge all daily files with the weekly files + if ulsMode == "full" { + err := mergeDailyFiles("l_amat", daysToDownload, "license") + if err != nil { + fmt.Printf("Failed to merge daily license files: %v\n", err) + } + } + + return nil +} + +func DownloadDailyApplications(wg *sync.WaitGroup) error { + defer wg.Done() + + ulsMode := os.Getenv("ULS_MODE") + var daysToDownload []string + + if ulsMode == "full" { + fmt.Println("Downloading ULS Daily Application data (full mode - all dailies since weekly)") + daysToDownload = getAllDailysSinceWeekly() + if len(daysToDownload) == 0 { + fmt.Println("No daily application files to download in full mode") + return nil + } + } else { + fmt.Println("Downloading ULS Daily Application data (partial mode)") + day := getPreviousBusinessDay() + daysToDownload = []string{day} + } + + for _, day := range daysToDownload { + dailyUrl := fmt.Sprintf("ftp://wirelessftp.fcc.gov:21/pub/uls/daily/a_am_%s.zip", day) + dailyFileName := fmt.Sprintf("a_am_%s.zip", day) + + fmt.Printf("Downloading daily application file for %s...\n", day) + err := downloader.FetchFtp(dailyFileName, dailyUrl) + if err != nil { + fmt.Printf("Failed to download daily application file for %s, skipping: %v\n", day, err) + continue + } + + // For full mode, extract to separate directory per day, then merge + extractDir := "a_amat" + if ulsMode == "full" { + extractDir = fmt.Sprintf("a_amat_daily_%s", day) + } + + files, err := downloader.Unzip(dailyFileName, extractDir) + if err != nil { + fmt.Printf("Failed to unzip daily application file for %s, skipping: %v\n", day, err) + continue + } + + fmt.Printf("Daily Application files for %s unzipped:\n%s\n", day, strings.Join(files, "\n")) + } + + // For full mode, merge all daily files with the weekly files + if ulsMode == "full" { + err := mergeDailyFiles("a_amat", daysToDownload, "application") + if err != nil { + fmt.Printf("Failed to merge daily application files: %v\n", err) + } + } + + return nil +} + +// mergeDailyFiles merges daily files with weekly files in full mode +func mergeDailyFiles(baseDir string, days []string, fileType string) error { + essentialFiles := []string{"AM.dat", "EN.dat", "HD.dat"} + if fileType == "application" { + essentialFiles = []string{"EN.dat", "HS.dat"} + } + + for _, filename := range essentialFiles { + baseFile := baseDir + "/" + filename + + // Open base file for appending + baseFileHandle, err := os.OpenFile(baseFile, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + fmt.Printf("Warning: Could not open base file %s for appending: %v\n", baseFile, err) + continue + } + defer baseFileHandle.Close() + + // Append each daily file + for _, day := range days { + dailyFile := fmt.Sprintf("%s_daily_%s/%s", baseDir, day, filename) + + dailyFileHandle, err := os.Open(dailyFile) + if err != nil { + fmt.Printf("Warning: Could not open daily file %s: %v\n", dailyFile, err) + continue + } + + // Copy daily file content to base file + _, err = io.Copy(baseFileHandle, dailyFileHandle) + dailyFileHandle.Close() + + if err != nil { + fmt.Printf("Warning: Could not merge daily file %s: %v\n", dailyFile, err) + } else { + fmt.Printf("Merged daily file %s into %s\n", dailyFile, baseFile) + } + } + } + + return nil +} + +func dailyFilesContainEssentialData(dir string) bool { + // Check if essential files exist and have content + essentialFiles := []string{"AM.dat", "EN.dat", "HD.dat"} + + for _, filename := range essentialFiles { + filepath := dir + "/" + filename + if _, err := os.Stat(filepath); os.IsNotExist(err) { + fmt.Printf("Essential file %s missing from daily download\n", filename) + return false + } + + // Check if file has content + info, err := os.Stat(filepath) + if err != nil || info.Size() == 0 { + fmt.Printf("Essential file %s is empty or unreadable\n", filename) + return false + } + } + + return true +} + func Process(calls *map[string]data.HamCall) { ProcessAM(calls) ProcessEN(calls) diff --git a/source/uls/uls_test.go b/source/uls/uls_test.go index b8a4c9c..9324a5c 100644 --- a/source/uls/uls_test.go +++ b/source/uls/uls_test.go @@ -239,4 +239,93 @@ func TestProcessIntegration(t *testing.T) { if w5test.Expiration != "01/01/2030" { t.Fatalf("Expected expiration '01/01/2030', got '%s'", w5test.Expiration) } +} + +func TestDailyFilesContainEssentialData(t *testing.T) { + // Create test directory structure + testDir := "/tmp/uls_daily_test" + os.MkdirAll(testDir, 0755) + defer os.RemoveAll(testDir) + + // Test case 1: All essential files exist with content + files := []string{"AM.dat", "EN.dat", "HD.dat"} + for _, filename := range files { + file, err := os.Create(testDir + "/" + filename) + if err != nil { + t.Fatalf("Failed to create test %s: %v", filename, err) + } + file.WriteString("test content") + file.Close() + } + + if !dailyFilesContainEssentialData(testDir) { + t.Fatalf("Expected true when all essential files exist with content") + } + + // Test case 2: Missing file + os.Remove(testDir + "/AM.dat") + if dailyFilesContainEssentialData(testDir) { + t.Fatalf("Expected false when essential file is missing") + } + + // Test case 3: Empty file + file, _ := os.Create(testDir + "/AM.dat") + file.Close() // Create empty file + if dailyFilesContainEssentialData(testDir) { + t.Fatalf("Expected false when essential file is empty") + } +} + +func TestDownloadSelection(t *testing.T) { + // Test default behavior (no environment variable) + os.Unsetenv("ULS_MODE") + + // Test partial mode + os.Setenv("ULS_MODE", "partial") + if os.Getenv("ULS_MODE") != "partial" { + t.Fatalf("Expected ULS_MODE to be 'partial'") + } + + // Test full mode + os.Setenv("ULS_MODE", "full") + if os.Getenv("ULS_MODE") != "full" { + t.Fatalf("Expected ULS_MODE to be 'full'") + } + + // Test weekly mode (default) + os.Unsetenv("ULS_MODE") + if os.Getenv("ULS_MODE") == "partial" || os.Getenv("ULS_MODE") == "full" { + t.Fatalf("Expected ULS_MODE to be unset") + } +} + +func TestGetPreviousBusinessDay(t *testing.T) { + // Test that the function returns a valid day code + day := getPreviousBusinessDay() + validDays := []string{"mon", "tue", "wed", "thu", "fri"} + + isValid := false + for _, validDay := range validDays { + if day == validDay { + isValid = true + break + } + } + + if !isValid { + t.Fatalf("Expected valid business day, got '%s'", day) + } +} + +func TestGetAllDailysSinceWeekly(t *testing.T) { + days := getAllDailysSinceWeekly() + + // Should only return business days + validDays := map[string]bool{"mon": true, "tue": true, "wed": true, "thu": true, "fri": true} + + for _, day := range days { + if !validDays[day] { + t.Fatalf("Expected only business days, got '%s'", day) + } + } } \ No newline at end of file