From df7e99a2f5aef452feece8de593991126a2187f9 Mon Sep 17 00:00:00 2001 From: Benjamin Cane Date: Sun, 22 Oct 2023 16:41:15 -0700 Subject: [PATCH] Working tests and better structure --- .github/workflows/build.yml | 21 ++ Makefile | 6 + pkg/airport/airport.go | 108 ++++++++++ pkg/airport/airport_test.go | 195 ++++++++++++++++++ pkg/airport/parsers/csv/csv.go | 91 ++++++++ .../parsers/csv/csv_test.go} | 32 ++- pkg/data/airport/airport.go | 39 ---- pkg/data/csvparser/csvparser.go | 112 ---------- 8 files changed, 433 insertions(+), 171 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 pkg/airport/airport.go create mode 100644 pkg/airport/airport_test.go create mode 100644 pkg/airport/parsers/csv/csv.go rename pkg/{data/csvparser/csvparser_test.go => airport/parsers/csv/csv_test.go} (91%) delete mode 100644 pkg/data/airport/airport.go delete mode 100644 pkg/data/csvparser/csvparser.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..13b12c9 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,21 @@ +name: build + +on: + push: + branches: + - main + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '1.21' + - name: Execute Tests + run: make tests + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 diff --git a/Makefile b/Makefile index 3f91db0..74cbea0 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,12 @@ build: docker run --rm -v `pwd`:/build -w /build/functions/build/init tinygo/tinygo:0.25.0 tinygo build -o /build/functions/build/init.wasm -target wasi /build/functions/src/init/main.go docker run --rm -v `pwd`:/build -w /build/functions/build/data/fetch tinygo/tinygo:0.25.0 tinygo build -o /build/functions/build/fetch.wasm -target wasi /build/functions/src/data/fetch/main.go +tests: + ## Run tests + mkdir -p coverage + go test -v -race -covermode=atomic -coverprofile=coverage/coverage.out ./... + go tool cover -html=coverage/coverage.out -o coverage/coverage.html + docker-compose: docker compose up diff --git a/pkg/airport/airport.go b/pkg/airport/airport.go new file mode 100644 index 0000000..531aa2f --- /dev/null +++ b/pkg/airport/airport.go @@ -0,0 +1,108 @@ +package airport + +import ( + "fmt" + "github.com/enescakir/emoji" +) + +// Airport is a struct that contains information about an airport. +type Airport struct { + // Continent is the continent the airport is located in. + Continent string `json:"continent"` + + // Emoji is the emoji of the airport location. (Example: πŸ‡ΊπŸ‡Έ, πŸ‡¨πŸ‡¦, πŸ‡¬πŸ‡§, etc.) + Emoji string `json:"emoji"` + + // ISOCountry is the ISO code of the country the airport is located in. + ISOCountry string `json:"iso_country"` + + // ISORegion is the ISO code of the region the airport is located in. + ISORegion string `json:"iso_region"` + + // LocalCode is the local code of the airport. This is a three letter code and is unique to each airport. + LocalCode string `json:"local_code"` + + // Municipality is the municipality the airport is located in. + Municipality string `json:"municipality"` + + // Name is the name of the airport. + Name string `json:"name"` + + // Type is the type of airport (examples: small_airport, heliport, closed, etc.). + Type string `json:"type"` + + // TypeEmoji is the emoji of the airport type (examples: πŸ›¬, 🚁, 🚧, etc.). + TypeEmoji string `json:"type_emoji"` + + // Status is the status of airport (examples: open, closed, etc.). + Status string `json:"status"` +} + +var ( + ErrMissingFields = fmt.Errorf("Missing required fields") + ErrUnknownCountry = fmt.Errorf("Unable to lookup country emoji") +) + +// Validate validates the airport struct and returns the airport with the emoji and country flag set. +func Validate(a Airport) (Airport, error) { + var err error + // Validate Minimum Fields Exist + if a.LocalCode == "" || a.Name == "" || a.ISOCountry == "" { + return a, ErrMissingFields + } + + // Set Airport Emoji + a, err = setTypeEmoji(a) + if err != nil { + return a, err + } + + // Set Country Flag + a, err = setCountryFlag(a) + if err != nil { + return a, err + } + + return a, nil +} + +// setTypeEmoji sets the emoji for the airport type. +func setTypeEmoji(a Airport) (Airport, error) { + switch a.Type { + case "heliport": + a.TypeEmoji = emoji.Helicopter.String() + a.Status = "open" + case "small_airport": + a.TypeEmoji = emoji.SmallAirplane.String() + a.Status = "open" + case "medium_airport", "large_airport": + a.TypeEmoji = emoji.Airplane.String() + a.Status = "open" + case "seaplane_base": + a.TypeEmoji = emoji.Anchor.String() + a.Status = "open" + case "balloonport": + a.TypeEmoji = emoji.Balloon.String() + a.Status = "open" + case "closed", "unknown": + a.Type = "unknown" + a.TypeEmoji = emoji.Construction.String() + a.Status = "closed" + default: + a.TypeEmoji = emoji.QuestionMark.String() + a.Status = "unknown" + } + + return a, nil +} + +// setCountryFlag sets the emoji for the country the airport is located in. +func setCountryFlag(a Airport) (Airport, error) { + // Set the Country Emoji + flag, err := emoji.CountryFlag(a.ISOCountry) + if err != nil { + return a, ErrUnknownCountry + } + a.Emoji = flag.String() + return a, nil +} diff --git a/pkg/airport/airport_test.go b/pkg/airport/airport_test.go new file mode 100644 index 0000000..df17302 --- /dev/null +++ b/pkg/airport/airport_test.go @@ -0,0 +1,195 @@ +package airport + +import ( + "testing" +) + +type AirportTestCase struct { + name string + input Airport + expected Airport + err error +} + +func TestAirport(t *testing.T) { + tt := []AirportTestCase{ + { + name: "Basic Test", + input: Airport{ + Name: "Test Airport", + LocalCode: "TST", + ISOCountry: "US", + }, + expected: Airport{ + Name: "Test Airport", + LocalCode: "TST", + ISOCountry: "US", + Emoji: "πŸ‡ΊπŸ‡Έ", + TypeEmoji: "❓", + Status: "unknown", + }, + }, + { + name: "Missing name", + input: Airport{ + LocalCode: "TST", + ISOCountry: "US", + }, + err: ErrMissingFields, + }, + { + name: "Unknown country", + input: Airport{ + Name: "Test Airport", + LocalCode: "TST", + ISOCountry: "TST", + }, + err: ErrUnknownCountry, + }, + { + name: "Heliport", + input: Airport{ + Name: "Test Heliport", + LocalCode: "TST", + ISOCountry: "US", + Type: "heliport", + }, + expected: Airport{ + Name: "Test Heliport", + LocalCode: "TST", + ISOCountry: "US", + Emoji: "πŸ‡ΊπŸ‡Έ", + TypeEmoji: "🚁", + Status: "open", + Type: "heliport", + }, + }, + { + name: "Small Airport", + input: Airport{ + Name: "Test Small Airport", + LocalCode: "TST", + ISOCountry: "US", + Type: "small_airport", + }, + expected: Airport{ + Name: "Test Small Airport", + LocalCode: "TST", + ISOCountry: "US", + Emoji: "πŸ‡ΊπŸ‡Έ", + TypeEmoji: "πŸ›©οΈ", + Status: "open", + Type: "small_airport", + }, + }, + { + name: "Medium Airport", + input: Airport{ + Name: "Test Medium Airport", + LocalCode: "TST", + ISOCountry: "US", + Type: "medium_airport", + }, + expected: Airport{ + Name: "Test Medium Airport", + LocalCode: "TST", + ISOCountry: "US", + Emoji: "πŸ‡ΊπŸ‡Έ", + TypeEmoji: "✈️", + Status: "open", + Type: "medium_airport", + }, + }, + { + name: "Large Airport", + input: Airport{ + Name: "Test Large Airport", + LocalCode: "TST", + ISOCountry: "US", + Type: "large_airport", + }, + expected: Airport{ + Name: "Test Large Airport", + LocalCode: "TST", + ISOCountry: "US", + Emoji: "πŸ‡ΊπŸ‡Έ", + TypeEmoji: "✈️", + Status: "open", + Type: "large_airport", + }, + }, + { + name: "Balloonport", + input: Airport{ + Name: "Test Balloonport", + LocalCode: "TST", + ISOCountry: "US", + Type: "balloonport", + }, + expected: Airport{ + Name: "Test Balloonport", + LocalCode: "TST", + ISOCountry: "US", + Emoji: "πŸ‡ΊπŸ‡Έ", + TypeEmoji: "🎈", + Status: "open", + Type: "balloonport", + }, + }, + { + name: "Seaplane Base", + input: Airport{ + Name: "Test Seaplane Base", + LocalCode: "TST", + ISOCountry: "US", + Type: "seaplane_base", + }, + expected: Airport{ + Name: "Test Seaplane Base", + LocalCode: "TST", + ISOCountry: "US", + Emoji: "πŸ‡ΊπŸ‡Έ", + Status: "open", + Type: "seaplane_base", + TypeEmoji: "βš“", + }, + }, + { + name: "Closed Airport", + input: Airport{ + Name: "Test Closed Airport", + LocalCode: "TST", + ISOCountry: "US", + Type: "closed", + }, + expected: Airport{ + Name: "Test Closed Airport", + LocalCode: "TST", + ISOCountry: "US", + Emoji: "πŸ‡ΊπŸ‡Έ", + Status: "closed", + Type: "unknown", + TypeEmoji: "🚧", + }, + }, + } + + for _, tc := range tt { + t.Run("Airport Validation: "+tc.name, func(t *testing.T) { + result, err := Validate(tc.input) + if err != nil { + if tc.err == nil { + t.Fatalf("Unexpected error: %v", err) + } + if tc.err != err { + t.Fatalf("Expected error %v, got %v", tc.err, err) + } + return + } + + if result != tc.expected { + t.Errorf("Expected %v, got %v", tc.expected, result) + } + }) + } +} diff --git a/pkg/airport/parsers/csv/csv.go b/pkg/airport/parsers/csv/csv.go new file mode 100644 index 0000000..2f4fa59 --- /dev/null +++ b/pkg/airport/parsers/csv/csv.go @@ -0,0 +1,91 @@ +package csv + +import ( + "encoding/csv" + "fmt" + "github.com/tarmac-project/example-airport-lookup-go/pkg/airport" + "io" +) + +var ( + ErrIsHeader = fmt.Errorf("record is a header") + ErrNotEnoughFields = fmt.Errorf("not enough fields") +) + +// Parser is a CSV parser for Airport data. +type Parser struct { + reader io.Reader +} + +// New creates a new Parser. +func New(reader io.Reader) (*Parser, error) { + p := &Parser{ + reader: reader, + } + return p, nil +} + +// Parse parses a CSV file of Airports returning a slice of Airports. +func (p *Parser) Parse() ([]airport.Airport, error) { + // Create a new CSV Reader + reader := csv.NewReader(p.reader) + + // Read the CSV file + rec, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("unable to read csv data - %w", err) + } + + // Create a slice of Airports + airports := make([]airport.Airport, 0, len(rec)) + + // Iterate over the CSV records + for _, r := range rec { + // Convert the record to an Airport + a, err := RecordToAirport(r) + if err != nil { + // Skip headers and records with not enough fields + if err == ErrIsHeader || err == ErrNotEnoughFields { + continue + } + return nil, fmt.Errorf("unable to convert record to airport - %w", err) + } + // Append the Airport to the slice + airports = append(airports, a) + } + + // Return the slice of Airports + return airports, nil +} + +// RecordToAirport converts a CSV record to an Airport. +func RecordToAirport(rec []string) (airport.Airport, error) { + // Check minimum length + if len(rec) < 15 { + return airport.Airport{}, ErrNotEnoughFields + } + + // Check if the record is a header + if rec[0] == "id" { + return airport.Airport{}, ErrIsHeader + } + + // Create a new Airport and set basic fields + a := airport.Airport{ + Continent: rec[7], + ISOCountry: rec[8], + ISORegion: rec[9], + LocalCode: rec[14], + Municipality: rec[10], + Name: rec[3], + Type: rec[2], + } + + // Validate the Airport Data + a, err := airport.Validate(a) + if err != nil { + return airport.Airport{}, fmt.Errorf("unable to validate airport - %w", err) + } + + return a, nil +} diff --git a/pkg/data/csvparser/csvparser_test.go b/pkg/airport/parsers/csv/csv_test.go similarity index 91% rename from pkg/data/csvparser/csvparser_test.go rename to pkg/airport/parsers/csv/csv_test.go index 806687e..c99c523 100644 --- a/pkg/data/csvparser/csvparser_test.go +++ b/pkg/airport/parsers/csv/csv_test.go @@ -1,8 +1,9 @@ -package csvparser +package csv import ( + "bytes" "github.com/enescakir/emoji" - "github.com/tarmac-project/example-airport-lookup-go/pkg/data/airport" + "github.com/tarmac-project/example-airport-lookup-go/pkg/airport" "reflect" "testing" ) @@ -16,23 +17,6 @@ type RecordToAirportTestCase struct { func TestRecordToAirport(t *testing.T) { tt := []RecordToAirportTestCase{ - { - name: "Basic airport record", - record: []string{"6523", "00A", "heliport", "Total RF Heliport", "40.070985", "-74.933689", "11", "NA", "US", "US-PA", "Bensalem", "no", "K00A", "", "00A", "https://www.penndot.pa.gov/TravelInPA/airports-pa/Pages/Total-RF-Heliport.aspx", ""}, - airport: airport.Airport{ - Continent: "NA", - Emoji: emoji.FlagForUnitedStates.String(), - ISOCountry: "US", - ISORegion: "US-PA", - LocalCode: "00A", - Municipality: "Bensalem", - Name: "Total RF Heliport", - Type: "heliport", - TypeEmoji: emoji.Helicopter.String(), - Status: "open", - }, - err: nil, - }, { name: "Heliport", record: []string{"6523", "00A", "heliport", "Total RF Heliport", "40.070985", "-74.933689", "11", "NA", "US", "US-PA", "Bensalem", "no", "K00A", "", "00A", "https://www.penndot.pa.gov/TravelInPA/airports-pa/Pages/Total-RF-Heliport.aspx", ""}, @@ -251,11 +235,19 @@ func TestParseAirport(t *testing.T) { for _, tc := range tt { t.Run("ParseAirport: "+tc.name, func(t *testing.T) { - results, err := ParseAirport(tc.raw) + // Create a new parser + p, err := New(bytes.NewReader(tc.raw)) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Parse the file + results, err := p.Parse() if err != nil { t.Fatalf("Unexpected error: %v", err) } + // Validate the records returned if len(results) != tc.records { t.Errorf("Expected %d records, got %d", tc.records, len(results)) } diff --git a/pkg/data/airport/airport.go b/pkg/data/airport/airport.go deleted file mode 100644 index 14746b6..0000000 --- a/pkg/data/airport/airport.go +++ /dev/null @@ -1,39 +0,0 @@ -package airport - -type Airport struct { - // Continent is the continent the airport is located in. - Continent string `json:"continent"` - - // Emoji is the emoji of the airport location. (Example: πŸ‡ΊπŸ‡Έ, πŸ‡¨πŸ‡¦, πŸ‡¬πŸ‡§, etc.) - Emoji string `json:"emoji"` - - // GPSCode is the GPS code of the airport. - GPSCode int `json:"gps_code"` - - // IATACode is the IATA code of the airport. This is a three letter code and is unique to each airport. - IATACode string `json:"iata_code"` - - // ISOCountry is the ISO code of the country the airport is located in. - ISOCountry string `json:"iso_country"` - - // ISORegion is the ISO code of the region the airport is located in. - ISORegion string `json:"iso_region"` - - // LocalCode is the local code of the airport. This is a three letter code and is unique to each airport. - LocalCode string `json:"local_code"` - - // Municipality is the municipality the airport is located in. - Municipality string `json:"municipality"` - - // Name is the name of the airport. - Name string `json:"name"` - - // Type is the type of airport (examples: small_airport, heliport, closed, etc.). - Type string `json:"type"` - - // TypeEmoji is the emoji of the airport type (examples: πŸ›¬, 🚁, 🚧, etc.). - TypeEmoji string `json:"type_emoji"` - - // Status is the status of airport (examples: open, closed, etc.). - Status string `json:"status"` -} diff --git a/pkg/data/csvparser/csvparser.go b/pkg/data/csvparser/csvparser.go deleted file mode 100644 index 845a901..0000000 --- a/pkg/data/csvparser/csvparser.go +++ /dev/null @@ -1,112 +0,0 @@ -package csvparser - -import ( - "bytes" - "encoding/csv" - "fmt" - "github.com/enescakir/emoji" - "github.com/tarmac-project/example-airport-lookup-go/pkg/data/airport" -) - -type Parser struct{} - -var ErrIsHeader = fmt.Errorf("record is a header") -var ErrNotEnoughFields = fmt.Errorf("not enough fields") - -// ParseAirport parses a CSV file of Airports returning a slice of Airports. -func ParseAirport(data []byte) ([]airport.Airport, error) { - // Create a new CSV Reader - reader := csv.NewReader(bytes.NewReader(data)) - - // Read the CSV file - rec, err := reader.ReadAll() - if err != nil { - return nil, fmt.Errorf("unable to read csv data - %w", err) - } - - // Create a slice of Airports - airports := make([]airport.Airport, 0, len(rec)) - - // Iterate over the CSV records - for _, r := range rec { - // Convert the record to an Airport - a, err := RecordToAirport(r) - if err != nil { - // Skip headers and records with not enough fields - if err == ErrIsHeader || err == ErrNotEnoughFields { - continue - } - return nil, fmt.Errorf("unable to convert record to airport - %w", err) - } - // Append the Airport to the slice - airports = append(airports, a) - } - - // Return the slice of Airports - return airports, nil -} - -// RecordToAirport converts a CSV record to an Airport. -func RecordToAirport(rec []string) (airport.Airport, error) { - // Check minimum length - if len(rec) < 15 { - return airport.Airport{}, ErrNotEnoughFields - } - - // Check if the record is a header - if rec[0] == "id" { - return airport.Airport{}, ErrIsHeader - } - - // Create a new Airport and set basic fields - a := airport.Airport{ - Continent: rec[7], - ISOCountry: rec[8], - ISORegion: rec[9], - LocalCode: rec[14], - Municipality: rec[10], - Name: rec[3], - Status: "open", - } - - // Set the Airport type and emoji - switch rec[2] { - case "heliport": - a.Type = "heliport" - a.TypeEmoji = emoji.Helicopter.String() - case "small_airport": - a.Type = "small_airport" - a.TypeEmoji = emoji.SmallAirplane.String() - case "medium_airport", "large_airport": - a.Type = rec[2] - a.TypeEmoji = emoji.Airplane.String() - case "seaplane_base": - a.Type = "seaplane_base" - a.TypeEmoji = emoji.Anchor.String() - case "balloonport": - a.Type = "balloonport" - a.TypeEmoji = emoji.Balloon.String() - case "closed": - a.Type = "unknown" - a.TypeEmoji = emoji.Construction.String() - a.Status = "closed" - default: - a.Type = rec[2] - a.TypeEmoji = emoji.QuestionMark.String() - a.Status = "unknown" - } - - // Set the Country Emoji - flag, err := emoji.CountryFlag(a.ISOCountry) - if err != nil { - return a, fmt.Errorf("unable to lookup country emoji - %w", err) - } - a.Emoji = flag.String() - - // Verify required data is present - if a.LocalCode == "" || a.Name == "" || a.ISOCountry == "" { - return airport.Airport{}, ErrNotEnoughFields - } - - return a, nil -}