|  | /* | 
|  | Copyright 2017 Google Inc. | 
|  |  | 
|  | Licensed under the Apache License, Version 2.0 (the "License"); | 
|  | you may not use this file except in compliance with the License. | 
|  | You may obtain a copy of the License at | 
|  |  | 
|  | https://www.apache.org/licenses/LICENSE-2.0 | 
|  |  | 
|  | Unless required by applicable law or agreed to in writing, software | 
|  | distributed under the License is distributed on an "AS IS" BASIS, | 
|  | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
|  | See the License for the specific language governing permissions and | 
|  | limitations under the License. | 
|  | */ | 
|  |  | 
|  | // Package utils contains common BookingService based helper functions. | 
|  | package utils | 
|  |  | 
|  | import ( | 
|  | "crypto/md5" | 
|  | "errors" | 
|  | "fmt" | 
|  | "io/ioutil" | 
|  | "log" | 
|  | "math/rand" | 
|  | "path" | 
|  | "sort" | 
|  | "strings" | 
|  |  | 
|  | "github.com/golang/protobuf/jsonpb" | 
|  | "github.com/golang/protobuf/proto" | 
|  | "github.com/google/go-cmp/cmp" | 
|  |  | 
|  | fpb "github.com/maps-booking-v3/feeds" | 
|  | mpb "github.com/maps-booking-v3/v3" | 
|  | ) | 
|  |  | 
|  | // SlotKey is a struct representing a unique service. | 
|  | type SlotKey struct { | 
|  | MerchantID string | 
|  | ServiceID  string | 
|  | StaffID    string | 
|  | RoomID     string | 
|  | } | 
|  |  | 
|  | // LogFlow is a convenience function for logging common flows.. | 
|  | func LogFlow(f string, status string) { | 
|  | log.Println(strings.Join([]string{"\n##########\n", status, f, "Flow", "\n##########"}, " ")) | 
|  | } | 
|  |  | 
|  | func parseServiceFeed(serviceFeed string) ([]*fpb.Service, error) { | 
|  | var feed fpb.ServiceFeed | 
|  | content, err := ioutil.ReadFile(serviceFeed) | 
|  | if err != nil { | 
|  | return nil, fmt.Errorf("unable to read input file: %v", err) | 
|  | } | 
|  | if path.Ext(serviceFeed) == ".json" { | 
|  | if err := jsonpb.UnmarshalString(string(content), &feed); err != nil { | 
|  | return nil, fmt.Errorf("unable to parse feed as json: %v", err) | 
|  | } | 
|  | } | 
|  | if path.Ext(serviceFeed) == ".pb3" { | 
|  | if err := proto.Unmarshal(content, &feed); err != nil { | 
|  | return nil, fmt.Errorf("unable to parse feed as pb3: %v", err) | 
|  | } | 
|  | } | 
|  |  | 
|  | if services := feed.GetService(); len(services) != 0 { | 
|  | return services, nil | 
|  | } | 
|  | return nil, errors.New("service feed is empty. At least one service must be present in feed") | 
|  | } | 
|  |  | 
|  | func merchantService(merchantID, serviceID string) string { | 
|  | return strings.Join([]string{merchantID, serviceID}, "||") | 
|  | } | 
|  |  | 
|  | func buildLineItem(availability *fpb.Availability, tickets []*fpb.TicketType) *mpb.LineItem { | 
|  | // If no ticket types return nil as there is nothing to build. | 
|  | if len(tickets) == 0 { | 
|  | return nil | 
|  | } | 
|  |  | 
|  | // Treated as a set. | 
|  | ticketMap := make(map[string]*fpb.TicketType) | 
|  | for _, ticketType := range tickets { | 
|  | if _, ok := ticketMap[ticketType.GetTicketTypeId()]; !ok { | 
|  | // Ticket type ids should be unique to a merchant service pair. | 
|  | // If they're not unique they will get dropped silently here. | 
|  | // We enforce minimal feed validation in the test client so it's | 
|  | // up to you to ensure UIDs. | 
|  | ticketMap[ticketType.GetTicketTypeId()] = ticketType | 
|  | } | 
|  | } | 
|  |  | 
|  | filteredTickets := tickets | 
|  | if len(availability.GetTicketTypeId()) != 0 { | 
|  | // Clear slice but preserve allocated memory. | 
|  | filteredTickets = filteredTickets[:0] | 
|  | for _, ticketTypeID := range availability.GetTicketTypeId() { | 
|  | if ticketType, ok := ticketMap[ticketTypeID]; ok { | 
|  | filteredTickets = append(filteredTickets, ticketType) | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | // If no ticket types return nil as there is nothing to build. | 
|  | if len(filteredTickets) == 0 { | 
|  | return nil | 
|  | } | 
|  |  | 
|  | lineItem := &mpb.LineItem{ | 
|  | ServiceId:   availability.GetServiceId(), | 
|  | StartSec:    availability.GetStartSec(), | 
|  | DurationSec: availability.GetDurationSec(), | 
|  | Price:       &mpb.Price{}, | 
|  | } | 
|  | for i := 0; i < int(availability.GetSpotsOpen()); i++ { | 
|  | // This is deterministic which is fine given that we just want to get a mix of ticket types. | 
|  | ticketTypeIndex := rand.Intn(len(filteredTickets)) | 
|  | // Calculate price of line item. | 
|  | if lineItem.GetPrice().GetCurrencyCode() == "" && filteredTickets[ticketTypeIndex].GetPrice().GetCurrencyCode() != "" { | 
|  | lineItem.Price.CurrencyCode = filteredTickets[ticketTypeIndex].GetPrice().GetCurrencyCode() | 
|  | } | 
|  | lineItem.Price.PriceMicros += filteredTickets[ticketTypeIndex].GetPrice().GetPriceMicros() | 
|  |  | 
|  | // Add ticket to line item. | 
|  | lineItem.Tickets = append(lineItem.Tickets, &mpb.LineItem_OrderedTickets{ | 
|  | TicketId: filteredTickets[ticketTypeIndex].GetTicketTypeId(), | 
|  | Count:    1, | 
|  | }) | 
|  | } | 
|  |  | 
|  | return lineItem | 
|  | } | 
|  |  | 
|  | // MerchantLineItemMapFrom attempts to build a collection of LineItems from a service and availability feed. | 
|  | func MerchantLineItemMapFrom(serviceFeed, availabilityFeed string, testSlots int) (map[string][]*mpb.LineItem, error) { | 
|  | services, err := parseServiceFeed(serviceFeed) | 
|  | if err != nil { | 
|  | return nil, err | 
|  | } | 
|  |  | 
|  | feedHasTicketType := false | 
|  | serviceTicketTypeMap := make(map[string][]*fpb.TicketType) | 
|  | for _, service := range services { | 
|  | merchantServiceID := merchantService(service.GetMerchantId(), service.GetServiceId()) | 
|  | for _, ticket := range service.GetTicketType() { | 
|  | // TicketType can't have an empty price message or ticket_type_id. If it does it's excluded from map. | 
|  | if ticket.GetPrice() == nil || len(ticket.GetTicketTypeId()) == 0 || cmp.Diff(fpb.Price{}, *ticket.GetPrice(), cmp.Comparer(proto.Equal)) == "" { | 
|  | continue | 
|  | } | 
|  |  | 
|  | if _, ok := serviceTicketTypeMap[merchantServiceID]; !ok { | 
|  | serviceTicketTypeMap[merchantServiceID] = []*fpb.TicketType{} | 
|  | } | 
|  | feedHasTicketType = true | 
|  | serviceTicketTypeMap[merchantServiceID] = append(serviceTicketTypeMap[merchantServiceID], ticket) | 
|  | } | 
|  | } | 
|  |  | 
|  | if !feedHasTicketType { | 
|  | return nil, errors.New("no valid ticket types found in service feed, please update service feed and retry") | 
|  | } | 
|  |  | 
|  | availabilities, err := AvailabilityFrom(availabilityFeed, testSlots) | 
|  | if err != nil { | 
|  | return nil, err | 
|  | } | 
|  |  | 
|  | merchantLineItemMap := make(map[string][]*mpb.LineItem) | 
|  | for _, availability := range availabilities { | 
|  | merchantServiceID := merchantService(availability.GetMerchantId(), availability.GetServiceId()) | 
|  | if tickets, ok := serviceTicketTypeMap[merchantServiceID]; ok { | 
|  | lineItem := buildLineItem(availability, tickets) | 
|  | // If lineItem can't be built, don't include in map | 
|  | if lineItem == nil { | 
|  | continue | 
|  | } | 
|  | merchantLineItemMap[availability.GetMerchantId()] = append(merchantLineItemMap[availability.GetMerchantId()], lineItem) | 
|  | } | 
|  | } | 
|  |  | 
|  | return merchantLineItemMap, nil | 
|  | } | 
|  |  | 
|  | // AvailabilityFrom parses the file specified in availabilityFeed, returning a random permutation of availability data, maximum entries specified in testSlots | 
|  | func AvailabilityFrom(availabilityFeed string, testSlots int) ([]*fpb.Availability, error) { | 
|  | LogFlow("Parse Input Feed", "Start") | 
|  | defer LogFlow("Parse Input Feed", "End") | 
|  |  | 
|  | var feed fpb.AvailabilityFeed | 
|  | content, err := ioutil.ReadFile(availabilityFeed) | 
|  | if err != nil { | 
|  | return nil, fmt.Errorf("unable to read input file: %v", err) | 
|  | } | 
|  | if path.Ext(availabilityFeed) == ".json" { | 
|  | if err := jsonpb.UnmarshalString(string(content), &feed); err != nil { | 
|  | return nil, fmt.Errorf("unable to parse feed as json: %v", err) | 
|  | } | 
|  | } | 
|  | if path.Ext(availabilityFeed) == ".pb3" { | 
|  | if err := proto.Unmarshal(content, &feed); err != nil { | 
|  | return nil, fmt.Errorf("unable to parse feed as pb3: %v", err) | 
|  | } | 
|  | } | 
|  |  | 
|  | var finalAvailability []*fpb.Availability | 
|  | var rawAvailability []*fpb.Availability | 
|  | for _, sa := range feed.GetServiceAvailability() { | 
|  | rawAvailability = append(rawAvailability, sa.GetAvailability()...) | 
|  | } | 
|  | if len(rawAvailability) == 0 || testSlots == 0 { | 
|  | return finalAvailability, errors.New("no valid availability in feed, exiting workflows") | 
|  | } | 
|  | if len(rawAvailability) <= testSlots { | 
|  | finalAvailability = rawAvailability | 
|  | } else { | 
|  | nums := rand.Perm(len(rawAvailability))[0:testSlots] | 
|  | for _, n := range nums { | 
|  | finalAvailability = append(finalAvailability, rawAvailability[n]) | 
|  | } | 
|  | } | 
|  | log.Printf("Selected %d slots out of a possible %d", len(finalAvailability), len(rawAvailability)) | 
|  | return finalAvailability, nil | 
|  | } | 
|  |  | 
|  | // ValidateBooking performs granular comparisons between all got and want Bookings. | 
|  | func ValidateBooking(got, want *mpb.Booking) error { | 
|  | if got.GetBookingId() == "" { | 
|  | return errors.New("booking_id is empty") | 
|  | } | 
|  | if diff := cmp.Diff(got.GetSlot(), want.GetSlot(), cmp.Comparer(proto.Equal)); diff != "" { | 
|  | return fmt.Errorf("slots differ (-got +want)\n%s", diff) | 
|  | } | 
|  | // UserId is the only required field for the partner to return. | 
|  | if diff := cmp.Diff(got.GetUserInformation().GetUserId(), want.GetUserInformation().GetUserId()); diff != "" { | 
|  | return fmt.Errorf("users differ (-got +want)\n%s", diff) | 
|  | } | 
|  | if diff := cmp.Diff(got.GetPaymentInformation(), want.GetPaymentInformation(), cmp.Comparer(proto.Equal)); diff != "" { | 
|  | return fmt.Errorf("payment information differs (-got +want)\n%s", diff) | 
|  | } | 
|  | // BookingStatus_CONFIRMED is the default case unless want overrides it. | 
|  | wantStatus := mpb.BookingStatus_CONFIRMED | 
|  | if want.GetStatus() != mpb.BookingStatus_BOOKING_STATUS_UNSPECIFIED { | 
|  | wantStatus = want.GetStatus() | 
|  | } | 
|  | if diff := cmp.Diff(got.GetStatus(), wantStatus); diff != "" { | 
|  | return fmt.Errorf("status differs (-got +want)\n%s", diff) | 
|  | } | 
|  | return nil | 
|  | } | 
|  |  | 
|  | // ValidateLineItems performs granular comparisons between got and want LineItem arrays. | 
|  | func ValidateLineItems(got, want []*mpb.LineItem, confirmStatus bool) error { | 
|  | if len(got) != len(want) { | 
|  | return fmt.Errorf("number of LineItems differ got %d want %d", len(got), len(want)) | 
|  | } | 
|  |  | 
|  | for _, lineItem := range got { | 
|  | orderTickets := OrderedTickets(lineItem.GetTickets()) | 
|  | sort.Sort(orderTickets) | 
|  | lineItem.Tickets = orderTickets | 
|  | } | 
|  | for _, lineItem := range want { | 
|  | orderTickets := OrderedTickets(lineItem.GetTickets()) | 
|  | sort.Sort(orderTickets) | 
|  | lineItem.Tickets = orderTickets | 
|  |  | 
|  | if confirmStatus { | 
|  | lineItem.Status = mpb.BookingStatus_CONFIRMED | 
|  | } | 
|  | } | 
|  | sort.Sort(LineItems(got)) | 
|  | sort.Sort(LineItems(want)) | 
|  |  | 
|  | if diff := cmp.Diff(got, want, cmp.Comparer(proto.Equal)); diff != "" { | 
|  | return fmt.Errorf("LineItems differ (-got +want)\n%s", diff) | 
|  | } | 
|  |  | 
|  | return nil | 
|  | } | 
|  |  | 
|  | // ValidateOrder performs granular comparisons between got and want Order messages. | 
|  | // Params are purposely copied. | 
|  | func ValidateOrder(got, want mpb.Order) error { | 
|  | if got.GetOrderId() == "" { | 
|  | return fmt.Errorf("no order id provided for Order %v", got) | 
|  | } | 
|  | want.OrderId = got.GetOrderId() | 
|  |  | 
|  | if err := ValidateLineItems(got.GetItem(), want.GetItem(), true); err != nil { | 
|  | return err | 
|  | } | 
|  |  | 
|  | // LineItems okay. Remove, free memory, and compare rest of proto. | 
|  | want.Item = nil | 
|  | got.Item = nil | 
|  | if diff := cmp.Diff(got, want, cmp.Comparer(proto.Equal)); diff != "" { | 
|  | return fmt.Errorf("order protos differ. LineItems excluded, already validated. (-got +want)\n%s", diff) | 
|  | } | 
|  |  | 
|  | return nil | 
|  | } | 
|  |  | 
|  | // ValidateOrders performs simple comparisons and set up before forwarding orders | 
|  | // individually to ValidateOrder. | 
|  | func ValidateOrders(got, want Orders) error { | 
|  | if len(got) != len(want) { | 
|  | return fmt.Errorf("number of Orders differ got %d want %d", len(got), len(want)) | 
|  | } | 
|  | sort.Sort(got) | 
|  | sort.Sort(want) | 
|  |  | 
|  | var errorStrings []string | 
|  | for i := 0; i < len(got); i++ { | 
|  | if err := ValidateOrder(*got[i], *want[i]); err != nil { | 
|  | errorStrings = append(errorStrings, err.Error()) | 
|  | } | 
|  | } | 
|  |  | 
|  | if len(errorStrings) != 0 { | 
|  | return errors.New(strings.Join(errorStrings, "\n")) | 
|  | } | 
|  | return nil | 
|  | } | 
|  |  | 
|  | func hashLineItemByTicketIds(l *mpb.LineItem) string { | 
|  | var uID []string | 
|  | for _, ticket := range l.GetTickets() { | 
|  | uID = append(uID, ticket.GetTicketId()) | 
|  | } | 
|  | return fmt.Sprintf("%x", md5.Sum([]byte(strings.Join(uID, `|`)))) | 
|  | } | 
|  |  | 
|  | // Orders is a convenience type for an Orders array | 
|  | type Orders []*mpb.Order | 
|  |  | 
|  | func (o Orders) Len() int { | 
|  | return len(o) | 
|  | } | 
|  |  | 
|  | func (o Orders) Less(i, j int) bool { | 
|  | return o[i].GetOrderId() < o[j].GetOrderId() | 
|  | } | 
|  |  | 
|  | func (o Orders) Swap(i, j int) { | 
|  | o[i], o[j] = o[j], o[i] | 
|  | } | 
|  |  | 
|  | // OrderedTickets is a convenience type for an OrderedTickets array | 
|  | type OrderedTickets []*mpb.LineItem_OrderedTickets | 
|  |  | 
|  | func (ot OrderedTickets) Len() int { | 
|  | return len(ot) | 
|  | } | 
|  |  | 
|  | func (ot OrderedTickets) Less(i, j int) bool { | 
|  | return ot[i].GetTicketId() < ot[j].GetTicketId() | 
|  | } | 
|  |  | 
|  | func (ot OrderedTickets) Swap(i, j int) { | 
|  | ot[i], ot[j] = ot[j], ot[i] | 
|  | } | 
|  |  | 
|  | // LineItems is a convenience type for a LineItem array | 
|  | // This sort interface defined below should be used iff | 
|  | // OrderedTickets have already been sorted AND | 
|  | // ticket ids are UIDs | 
|  | type LineItems []*mpb.LineItem | 
|  |  | 
|  | func (l LineItems) Len() int { | 
|  | return len(l) | 
|  | } | 
|  |  | 
|  | func (l LineItems) Less(i, j int) bool { | 
|  | return hashLineItemByTicketIds(l[i]) < hashLineItemByTicketIds(l[j]) | 
|  | } | 
|  |  | 
|  | func (l LineItems) Swap(i, j int) { | 
|  | l[i], l[j] = l[j], l[i] | 
|  | } | 
|  |  | 
|  | // BuildSlotFrom creates a bookingservice slot from an feed availability record. | 
|  | func BuildSlotFrom(a *fpb.Availability) (*mpb.Slot, error) { | 
|  | r := a.GetResources() | 
|  | return &mpb.Slot{ | 
|  | MerchantId:      a.GetMerchantId(), | 
|  | ServiceId:       a.GetServiceId(), | 
|  | StartSec:        a.GetStartSec(), | 
|  | DurationSec:     a.GetDurationSec(), | 
|  | AvailabilityTag: a.GetAvailabilityTag(), | 
|  | Resources: &mpb.ResourceIds{ | 
|  | StaffId:   r.GetStaffId(), | 
|  | RoomId:    r.GetRoomId(), | 
|  | PartySize: r.GetPartySize(), | 
|  | }, | 
|  | }, nil | 
|  | } | 
|  |  | 
|  | // BuildMerchantServiceMap creates a key value pair of unique services to all of their availability slots. | 
|  | func BuildMerchantServiceMap(av []*fpb.Availability) map[SlotKey][]*fpb.Availability { | 
|  | m := make(map[SlotKey][]*fpb.Availability) | 
|  | for _, a := range av { | 
|  | key := SlotKey{ | 
|  | MerchantID: a.GetMerchantId(), | 
|  | ServiceID:  a.GetServiceId(), | 
|  | StaffID:    a.GetResources().GetStaffId(), | 
|  | RoomID:     a.GetResources().GetRoomId(), | 
|  | } | 
|  | m[key] = append(m[key], a) | 
|  | } | 
|  | return m | 
|  | } |