blob: 306c0d2b3cb9ae0ac5eb2e06dd2aa673ae6d747a [file] [log] [blame]
/*
Copyright 2019 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/sha256"
"errors"
"fmt"
"io/ioutil"
"log"
"math/rand"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"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/v3waitlist"
)
const logFile = "http_test_client_log_"
// SlotKey is a struct representing a unique service.
type SlotKey struct {
MerchantID string
ServiceID string
StaffID string
RoomID string
}
func createLogFile(outPath string) (*os.File, error) {
var err error
if outPath == "" {
outPath, err = os.Getwd()
if err != nil {
return nil, err
}
}
now := time.Now().UTC()
nowString := fmt.Sprintf("%d-%02d-%02d_%02d-%02d-%02d", now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second())
outFile := filepath.Join(outPath, fmt.Sprintf("%s%s", logFile, nowString))
return os.Create(outFile)
}
// MakeLogger creates a logger that either logs to terminal or to a file. If outputToTerminal
// is true, outputDir will be ignored.
func MakeLogger(outputToTerminal bool, outputDir string) (*log.Logger, *os.File, error) {
var logger *log.Logger
if outputToTerminal {
logger = log.New(os.Stderr, "", log.Flags())
return logger, nil, nil
}
f, err := createLogFile(outputDir)
if err != nil {
return nil, nil, err
}
logger = log.New(f, "", log.Flags())
return logger, f, nil
}
// LogFlow is a convenience function for logging common flows..
func LogFlow(logger *log.Logger, f string, status string) {
logger.Println(strings.Join([]string{"\n##########\n", status, f, "Flow", "\n##########"}, " "))
}
// ReduceServices randomly selects and returns numTestServices from the provided services.
func ReduceServices(logger *log.Logger, 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])
}
}
logger.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
}
// BuildLineItemMap creats a collection of LineItems from slices of services and availabilities.
func BuildLineItemMap(services []*fpb.Service, availabilities []*fpb.Availability) (map[string][]*mpb.LineItem, error) {
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 services, please update services and retry")
}
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)
}
}
if len(merchantLineItemMap) == 0 {
return nil, errors.New("no availability slots with ticket type IDs matching those in the service were found")
}
return merchantLineItemMap, nil
}
// AvailabilityFrom parses the file specified in availabilityFeed, returning a random permutation of availability data, maximum entries specified in testSlots
func AvailabilityFrom(logger *log.Logger, availabilityFeed string, testSlots int, forRescheduling bool) ([]*fpb.Availability, error) {
LogFlow(logger, "Parse Input Feed", "Start")
defer LogFlow(logger, "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])
}
}
}
logger.Printf("Selected %d slots out of a possible %d", len(finalAvailability), len(rawAvailability))
return finalAvailability, nil
}
func slotDiff(got, want *mpb.Slot) string {
// CONFIRMATION_MODE_SYNCHRONOUS and CONFIRMATION_MODE_UNSPECIFIED should be treated as equivalent.
var gotConfirmationMode, wantConfirmationMode mpb.ConfirmationMode
var gotComp, wantComp mpb.Slot
if got != nil {
gotComp = *got
gotConfirmationMode = got.GetConfirmationMode()
gotComp.ConfirmationMode = mpb.ConfirmationMode_CONFIRMATION_MODE_UNSPECIFIED
}
if want != nil {
wantComp = *want
wantConfirmationMode = want.GetConfirmationMode()
wantComp.ConfirmationMode = mpb.ConfirmationMode_CONFIRMATION_MODE_UNSPECIFIED
}
if diff := cmp.Diff(&gotComp, &wantComp, cmp.Comparer(proto.Equal)); diff != "" {
return diff
}
if (wantConfirmationMode == mpb.ConfirmationMode_CONFIRMATION_MODE_ASYNCHRONOUS) !=
(gotConfirmationMode == mpb.ConfirmationMode_CONFIRMATION_MODE_ASYNCHRONOUS) {
return cmp.Diff(gotConfirmationMode, wantConfirmationMode)
}
return ""
}
// 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 := slotDiff(got.GetSlot(), want.GetSlot()); 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
}
// AvailabilityToSlotTime converts an Availability object to SlotTime.
func AvailabilityToSlotTime(a *fpb.Availability) *mpb.SlotTime {
slot := &mpb.SlotTime{
ServiceId: a.GetServiceId(),
StartSec: a.GetStartSec(),
DurationSec: a.GetDurationSec(),
AvailabilityTag: a.GetAvailabilityTag()}
if a.Resources != nil {
r := a.GetResources()
slot.ResourceIds = &mpb.ResourceIds{
StaffId: r.GetStaffId(),
RoomId: r.GetRoomId(),
PartySize: r.GetPartySize(),
}
}
return slot
}
// 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", sha256.Sum256([]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) {
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
}
slot := &mpb.Slot{
MerchantId: a.GetMerchantId(),
ServiceId: a.GetServiceId(),
StartSec: a.GetStartSec(),
DurationSec: a.GetDurationSec(),
AvailabilityTag: a.GetAvailabilityTag(),
ConfirmationMode: confirmationMode,
}
if a.Resources != nil {
r := a.GetResources()
slot.Resources = &mpb.ResourceIds{
StaffId: r.GetStaffId(),
RoomId: r.GetRoomId(),
PartySize: r.GetPartySize(),
}
}
return slot, nil
}
// SplitAvailabilityByMerchant splits the list of availabilities by merchant. This is necessary if BatchAvailabilityLookup
// is enabled, since each BAL request handles one merchant.
func SplitAvailabilityByMerchant(av []*fpb.Availability) map[string][]*fpb.Availability {
ret := make(map[string][]*fpb.Availability)
for _, a := range av {
ret[a.GetMerchantId()] = append(ret[a.GetMerchantId()], a)
}
return ret
}
// 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
for _, a := range av {
if m == "" {
m = a.GetMerchantId()
} else if m != a.GetMerchantId() {
return nil, fmt.Errorf("BuildBatchAvailabilityLookupRequestFrom failed, got multiple merchant ids: %s, %s", m, a.GetMerchantId())
}
st = append(st, AvailabilityToSlotTime(a))
}
return &mpb.BatchAvailabilityLookupRequest{
MerchantId: m,
SlotTime: st,
}, nil
}
// BuildBatchAvailabilityLookupResponseFrom creates a BatchAvailabilityLookupResponse from a list of input availability slots.
func BuildBatchAvailabilityLookupResponseFrom(av []*fpb.Availability) (*mpb.BatchAvailabilityLookupResponse, error) {
var sta []*mpb.SlotTimeAvailability
var m string
for _, a := range av {
if m == "" {
m = a.GetMerchantId()
} else if m != a.GetMerchantId() {
return nil, fmt.Errorf("BuildBatchAvailabilityLookupRequestFrom failed, got multiple merchant ids: %s, %s", m, a.GetMerchantId())
}
sta = append(sta, &mpb.SlotTimeAvailability{
Available: a.SpotsOpen > 0,
SlotTime: AvailabilityToSlotTime(a),
})
}
return &mpb.BatchAvailabilityLookupResponse{
SlotTimeAvailability: sta,
}, 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 length not specified")
}
if waitLength.GetPartiesAheadCount() < 0 {
return errors.New("parties ahead count < 0")
}
estimatedSeatTimeRange := waitLength.GetEstimatedSeatTimeRange()
if estimatedSeatTimeRange == nil {
return nil
}
if estimatedSeatTimeRange.GetStartSeconds() <= 0 {
return errors.New("estimated seat time range provided and start seconds <= 0")
}
if estimatedSeatTimeRange.GetEndSeconds() <= 0 {
return errors.New("estimated seat time range provided and 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)
}