blob: d355832379587d418165f50dea4f73cf17dd8b8a [file] [log] [blame]
/*
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"
wpb "github.com/maps-booking-v3/waitlist"
)
// 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##########"}, " "))
}
// ReduceServices randomly selects and returns numTestServices from the provided services.
func ReduceServices(allServices []*fpb.Service, numTestServices int) []*fpb.Service {
reducedServices := make([]*fpb.Service, 0, numTestServices)
if len(allServices) <= numTestServices {
reducedServices = allServices
} else {
for _, n := range rand.Perm(len(allServices))[0:numTestServices] {
reducedServices = append(reducedServices, allServices[n])
}
}
log.Printf("Selected %d services out of a possible %d", len(reducedServices), len(allServices))
return reducedServices
}
// ParseServiceFeed returns a slice of services for each service in the feed.
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, false)
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, forRescheduling bool) ([]*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])
if !forRescheduling {
continue
}
if n > 0 {
finalAvailability = append(finalAvailability, rawAvailability[n-1])
}
if n < testSlots-1 {
finalAvailability = append(finalAvailability, rawAvailability[n+1])
}
}
}
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 the slot is async, in which
// case the default is BookingStatus_PENDING_MERCHANT_CONFIRMATION.
wantStatus := mpb.BookingStatus_CONFIRMED
if want.GetSlot().GetConfirmationMode() == mpb.ConfirmationMode_CONFIRMATION_MODE_ASYNCHRONOUS {
wantStatus = mpb.BookingStatus_PENDING_MERCHANT_CONFIRMATION
}
// If an alternate status is specified, we compare against it instead of the default.
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()
confirmationMode := mpb.ConfirmationMode_CONFIRMATION_MODE_UNSPECIFIED
if a.GetConfirmationMode() == fpb.Availability_CONFIRMATION_MODE_SYNCHRONOUS {
confirmationMode = mpb.ConfirmationMode_CONFIRMATION_MODE_SYNCHRONOUS
} else if a.GetConfirmationMode() == fpb.Availability_CONFIRMATION_MODE_ASYNCHRONOUS {
confirmationMode = mpb.ConfirmationMode_CONFIRMATION_MODE_ASYNCHRONOUS
}
return &mpb.Slot{
MerchantId: a.GetMerchantId(),
ServiceId: a.GetServiceId(),
StartSec: a.GetStartSec(),
DurationSec: a.GetDurationSec(),
AvailabilityTag: a.GetAvailabilityTag(),
ConfirmationMode: confirmationMode,
Resources: &mpb.ResourceIds{
StaffId: r.GetStaffId(),
RoomId: r.GetRoomId(),
PartySize: r.GetPartySize(),
},
}, nil
}
// BuildBatchAvailabilityLookupRequestFrom creates a BatchAvailabilityLookupRequest from a list of input availability slots.
func BuildBatchAvailabilityLookupRequestFrom(av []*fpb.Availability) (*mpb.BatchAvailabilityLookupRequest, error) {
var st []*mpb.SlotTime
var m string
var s string
for _, a := range av {
if m == "" {
m = a.GetMerchantId()
s = a.GetServiceId()
} else if m != a.GetMerchantId() {
return nil, fmt.Errorf("BuildBatchAvailabilityLookupRequestFrom failed, got multiple merchant ids: %s, %s", m, a.GetMerchantId())
} else if s != a.GetServiceId() {
return nil, fmt.Errorf("BuildBatchAvailabilityLookupRequestFrom failed, got multiple service ids: %s, %s", s, a.GetServiceId())
}
r := a.GetResources()
st = append(st, &mpb.SlotTime{
ServiceId: s,
StartSec: a.GetStartSec(),
DurationSec: a.GetDurationSec(),
AvailabilityTag: a.GetAvailabilityTag(),
ResourceIds: &mpb.ResourceIds{
StaffId: r.GetStaffId(),
RoomId: r.GetRoomId(),
PartySize: r.GetPartySize(),
},
})
}
return &mpb.BatchAvailabilityLookupRequest{
MerchantId: m,
SlotTime: st,
}, 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
}
// ValidateWaitEstimate validates all the fields are populated for the wait estimate.
func ValidateWaitEstimate(waitEstimate *wpb.WaitEstimate) error {
if waitEstimate.GetPartySize() <= 0 {
return errors.New("party size <= 0")
}
waitLength := waitEstimate.GetWaitLength()
if waitLength == nil {
return errors.New("wait estimate not specified")
}
if waitLength.GetPartiesAheadCount() < 0 {
return errors.New("parties ahead count < 0")
}
estimatedSeatTimeRange := waitLength.GetEstimatedSeatTimeRange()
if estimatedSeatTimeRange == nil {
return errors.New("estimated seat time range not specified")
}
if estimatedSeatTimeRange.GetStartSeconds() <= 0 {
return errors.New("start seconds <= 0")
}
if estimatedSeatTimeRange.GetEndSeconds() <= 0 {
return errors.New("end seconds <= 0")
}
return nil
}
// ValidateWaitlistEntry validates all the fields are populated for the waitlist entry.
func ValidateWaitlistEntry(waitlistEntry *wpb.WaitlistEntry) error {
if waitlistEntry.GetWaitlistEntryState() == wpb.WaitlistEntryState_WAITLIST_ENTRY_STATE_UNSPECIFIED {
return errors.New("waitlist entry state not specified")
}
stateTimes := waitlistEntry.GetWaitlistEntryStateTimes()
if stateTimes == nil {
return errors.New("WaitlistEntryStateTimes not specified")
}
if stateTimes.GetCreatedTimeSeconds() <= 0 {
return errors.New("created time seconds <= 0")
}
waitEstimate := waitlistEntry.GetWaitEstimate()
if waitEstimate == nil {
return errors.New("wait estimate not specified")
}
return ValidateWaitEstimate(waitEstimate)
}