blob: c95e19883be77dd826ee782c16f937952125d637 [file] [log] [blame]
Will Silberman1484aa42018-03-23 15:25:40 -07001/*
2Copyright 2017 Google Inc.
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8https://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16
17// Package utils contains common BookingService based helper functions.
18package utils
19
20import (
Christopher Cawdreyd5c894b2018-05-25 15:41:27 -070021 "crypto/md5"
Will Silberman1484aa42018-03-23 15:25:40 -070022 "errors"
23 "fmt"
24 "io/ioutil"
25 "log"
26 "math/rand"
27 "path"
Christopher Cawdreyd5c894b2018-05-25 15:41:27 -070028 "sort"
Will Silberman1484aa42018-03-23 15:25:40 -070029 "strings"
30
31 "github.com/golang/protobuf/jsonpb"
32 "github.com/golang/protobuf/proto"
33 "github.com/google/go-cmp/cmp"
34
Will Silberman572817a2018-03-23 15:43:15 -070035 fpb "github.com/maps-booking-v3/feeds"
36 mpb "github.com/maps-booking-v3/v3"
Will Silberman1484aa42018-03-23 15:25:40 -070037)
38
39// SlotKey is a struct representing a unique service.
40type SlotKey struct {
41 MerchantID string
42 ServiceID string
43 StaffID string
44 RoomID string
45}
46
47// LogFlow is a convenience function for logging common flows..
48func LogFlow(f string, status string) {
49 log.Println(strings.Join([]string{"\n##########\n", status, f, "Flow", "\n##########"}, " "))
50}
51
Christopher Cawdreyd5c894b2018-05-25 15:41:27 -070052func parseServiceFeed(serviceFeed string) ([]*fpb.Service, error) {
53 var feed fpb.ServiceFeed
54 content, err := ioutil.ReadFile(serviceFeed)
55 if err != nil {
56 return nil, fmt.Errorf("unable to read input file: %v", err)
57 }
58 if path.Ext(serviceFeed) == ".json" {
59 if err := jsonpb.UnmarshalString(string(content), &feed); err != nil {
60 return nil, fmt.Errorf("unable to parse feed as json: %v", err)
61 }
62 }
63 if path.Ext(serviceFeed) == ".pb3" {
64 if err := proto.Unmarshal(content, &feed); err != nil {
65 return nil, fmt.Errorf("unable to parse feed as pb3: %v", err)
66 }
67 }
68
69 if services := feed.GetService(); len(services) != 0 {
70 return services, nil
71 }
72 return nil, errors.New("service feed is empty. At least one service must be present in feed")
73}
74
75func merchantService(merchantID, serviceID string) string {
76 return strings.Join([]string{merchantID, serviceID}, "||")
77}
78
79func buildLineItem(availability *fpb.Availability, tickets []*fpb.TicketType) *mpb.LineItem {
Christopher Cawdreye5606e02018-06-12 14:13:52 -040080 // If no ticket types return nil as there is nothing to build.
81 if len(tickets) == 0 {
82 return nil
83 }
84
85 // Treated as a set.
86 ticketMap := make(map[string]*fpb.TicketType)
87 for _, ticketType := range tickets {
88 if _, ok := ticketMap[ticketType.GetTicketTypeId()]; !ok {
89 // Ticket type ids should be unique to a merchant service pair.
90 // If they're not unique they will get dropped silently here.
91 // We enforce minimal feed validation in the test client so it's
92 // up to you to ensure UIDs.
93 ticketMap[ticketType.GetTicketTypeId()] = ticketType
94 }
95 }
96
97 filteredTickets := tickets
98 if len(availability.GetTicketTypeId()) != 0 {
99 // Clear slice but preserve allocated memory.
100 filteredTickets = filteredTickets[:0]
101 for _, ticketTypeID := range availability.GetTicketTypeId() {
102 if ticketType, ok := ticketMap[ticketTypeID]; ok {
103 filteredTickets = append(filteredTickets, ticketType)
104 }
105 }
106 }
107
108 // If no ticket types return nil as there is nothing to build.
109 if len(filteredTickets) == 0 {
110 return nil
111 }
112
Christopher Cawdreyd5c894b2018-05-25 15:41:27 -0700113 lineItem := &mpb.LineItem{
114 ServiceId: availability.GetServiceId(),
115 StartSec: availability.GetStartSec(),
116 DurationSec: availability.GetDurationSec(),
117 Price: &mpb.Price{},
118 }
Christopher Cawdreyd5c894b2018-05-25 15:41:27 -0700119 for i := 0; i < int(availability.GetSpotsOpen()); i++ {
Christopher Cawdrey7ace6c02018-06-06 18:18:21 -0400120 // This is deterministic which is fine given that we just want to get a mix of ticket types.
Christopher Cawdreye5606e02018-06-12 14:13:52 -0400121 ticketTypeIndex := rand.Intn(len(filteredTickets))
Christopher Cawdreyd5c894b2018-05-25 15:41:27 -0700122 // Calculate price of line item.
Christopher Cawdreye5606e02018-06-12 14:13:52 -0400123 if lineItem.GetPrice().GetCurrencyCode() == "" && filteredTickets[ticketTypeIndex].GetPrice().GetCurrencyCode() != "" {
124 lineItem.Price.CurrencyCode = filteredTickets[ticketTypeIndex].GetPrice().GetCurrencyCode()
Christopher Cawdreyd5c894b2018-05-25 15:41:27 -0700125 }
Christopher Cawdreye5606e02018-06-12 14:13:52 -0400126 lineItem.Price.PriceMicros += filteredTickets[ticketTypeIndex].GetPrice().GetPriceMicros()
Christopher Cawdreyd5c894b2018-05-25 15:41:27 -0700127
128 // Add ticket to line item.
129 lineItem.Tickets = append(lineItem.Tickets, &mpb.LineItem_OrderedTickets{
Christopher Cawdreye5606e02018-06-12 14:13:52 -0400130 TicketId: filteredTickets[ticketTypeIndex].GetTicketTypeId(),
Christopher Cawdreyd5c894b2018-05-25 15:41:27 -0700131 Count: 1,
132 })
133 }
134
135 return lineItem
136}
137
138// MerchantLineItemMapFrom attempts to build a collection of LineItems from a service and availability feed.
139func MerchantLineItemMapFrom(serviceFeed, availabilityFeed string, testSlots int) (map[string][]*mpb.LineItem, error) {
140 services, err := parseServiceFeed(serviceFeed)
141 if err != nil {
142 return nil, err
143 }
144
145 feedHasTicketType := false
146 serviceTicketTypeMap := make(map[string][]*fpb.TicketType)
147 for _, service := range services {
148 merchantServiceID := merchantService(service.GetMerchantId(), service.GetServiceId())
149 for _, ticket := range service.GetTicketType() {
150 // TicketType can't have an empty price message or ticket_type_id. If it does it's excluded from map.
Christopher Cawdrey7ace6c02018-06-06 18:18:21 -0400151 if ticket.GetPrice() == nil || len(ticket.GetTicketTypeId()) == 0 || cmp.Diff(fpb.Price{}, *ticket.GetPrice(), cmp.Comparer(proto.Equal)) == "" {
Christopher Cawdreyd5c894b2018-05-25 15:41:27 -0700152 continue
153 }
154
155 if _, ok := serviceTicketTypeMap[merchantServiceID]; !ok {
156 serviceTicketTypeMap[merchantServiceID] = []*fpb.TicketType{}
157 }
158 feedHasTicketType = true
159 serviceTicketTypeMap[merchantServiceID] = append(serviceTicketTypeMap[merchantServiceID], ticket)
160 }
161 }
162
163 if !feedHasTicketType {
164 return nil, errors.New("no valid ticket types found in service feed, please update service feed and retry")
165 }
166
167 availabilities, err := AvailabilityFrom(availabilityFeed, testSlots)
168 if err != nil {
169 return nil, err
170 }
171
172 merchantLineItemMap := make(map[string][]*mpb.LineItem)
173 for _, availability := range availabilities {
174 merchantServiceID := merchantService(availability.GetMerchantId(), availability.GetServiceId())
175 if tickets, ok := serviceTicketTypeMap[merchantServiceID]; ok {
176 lineItem := buildLineItem(availability, tickets)
177 // If lineItem can't be built, don't include in map
178 if lineItem == nil {
179 continue
180 }
181 merchantLineItemMap[availability.GetMerchantId()] = append(merchantLineItemMap[availability.GetMerchantId()], lineItem)
182 }
183 }
184
185 return merchantLineItemMap, nil
186}
187
Will Silberman1484aa42018-03-23 15:25:40 -0700188// AvailabilityFrom parses the file specified in availabilityFeed, returning a random permutation of availability data, maximum entries specified in testSlots
189func AvailabilityFrom(availabilityFeed string, testSlots int) ([]*fpb.Availability, error) {
190 LogFlow("Parse Input Feed", "Start")
191 defer LogFlow("Parse Input Feed", "End")
192
193 var feed fpb.AvailabilityFeed
194 content, err := ioutil.ReadFile(availabilityFeed)
195 if err != nil {
196 return nil, fmt.Errorf("unable to read input file: %v", err)
197 }
198 if path.Ext(availabilityFeed) == ".json" {
199 if err := jsonpb.UnmarshalString(string(content), &feed); err != nil {
200 return nil, fmt.Errorf("unable to parse feed as json: %v", err)
201 }
202 }
203 if path.Ext(availabilityFeed) == ".pb3" {
204 if err := proto.Unmarshal(content, &feed); err != nil {
205 return nil, fmt.Errorf("unable to parse feed as pb3: %v", err)
206 }
207 }
208
209 var finalAvailability []*fpb.Availability
210 var rawAvailability []*fpb.Availability
211 for _, sa := range feed.GetServiceAvailability() {
212 rawAvailability = append(rawAvailability, sa.GetAvailability()...)
213 }
214 if len(rawAvailability) == 0 || testSlots == 0 {
215 return finalAvailability, errors.New("no valid availability in feed, exiting workflows")
216 }
217 if len(rawAvailability) <= testSlots {
218 finalAvailability = rawAvailability
219 } else {
220 nums := rand.Perm(len(rawAvailability))[0:testSlots]
221 for _, n := range nums {
222 finalAvailability = append(finalAvailability, rawAvailability[n])
223 }
224 }
225 log.Printf("Selected %d slots out of a possible %d", len(finalAvailability), len(rawAvailability))
226 return finalAvailability, nil
227}
228
229// ValidateBooking performs granular comparisons between all got and want Bookings.
Christopher Cawdreyd5c894b2018-05-25 15:41:27 -0700230func ValidateBooking(got, want *mpb.Booking) error {
Will Silberman1484aa42018-03-23 15:25:40 -0700231 if got.GetBookingId() == "" {
232 return errors.New("booking_id is empty")
233 }
234 if diff := cmp.Diff(got.GetSlot(), want.GetSlot(), cmp.Comparer(proto.Equal)); diff != "" {
235 return fmt.Errorf("slots differ (-got +want)\n%s", diff)
236 }
237 // UserId is the only required field for the partner to return.
238 if diff := cmp.Diff(got.GetUserInformation().GetUserId(), want.GetUserInformation().GetUserId()); diff != "" {
239 return fmt.Errorf("users differ (-got +want)\n%s", diff)
240 }
241 if diff := cmp.Diff(got.GetPaymentInformation(), want.GetPaymentInformation(), cmp.Comparer(proto.Equal)); diff != "" {
242 return fmt.Errorf("payment information differs (-got +want)\n%s", diff)
243 }
244 // BookingStatus_CONFIRMED is the default case unless want overrides it.
245 wantStatus := mpb.BookingStatus_CONFIRMED
246 if want.GetStatus() != mpb.BookingStatus_BOOKING_STATUS_UNSPECIFIED {
247 wantStatus = want.GetStatus()
248 }
249 if diff := cmp.Diff(got.GetStatus(), wantStatus); diff != "" {
250 return fmt.Errorf("status differs (-got +want)\n%s", diff)
251 }
252 return nil
253}
254
Christopher Cawdreyd5c894b2018-05-25 15:41:27 -0700255// ValidateLineItems performs granular comparisons between got and want LineItem arrays.
256func ValidateLineItems(got, want []*mpb.LineItem, confirmStatus bool) error {
257 if len(got) != len(want) {
258 return fmt.Errorf("number of LineItems differ got %d want %d", len(got), len(want))
259 }
260
261 for _, lineItem := range got {
262 orderTickets := OrderedTickets(lineItem.GetTickets())
263 sort.Sort(orderTickets)
264 lineItem.Tickets = orderTickets
265 }
266 for _, lineItem := range want {
267 orderTickets := OrderedTickets(lineItem.GetTickets())
268 sort.Sort(orderTickets)
269 lineItem.Tickets = orderTickets
270
271 if confirmStatus {
272 lineItem.Status = mpb.BookingStatus_CONFIRMED
273 }
274 }
275 sort.Sort(LineItems(got))
276 sort.Sort(LineItems(want))
277
278 if diff := cmp.Diff(got, want, cmp.Comparer(proto.Equal)); diff != "" {
279 return fmt.Errorf("LineItems differ (-got +want)\n%s", diff)
280 }
281
282 return nil
283}
284
285// ValidateOrder performs granular comparisons between got and want Order messages.
286// Params are purposely copied.
287func ValidateOrder(got, want mpb.Order) error {
288 if got.GetOrderId() == "" {
289 return fmt.Errorf("no order id provided for Order %v", got)
290 }
291 want.OrderId = got.GetOrderId()
292
293 if err := ValidateLineItems(got.GetItem(), want.GetItem(), true); err != nil {
294 return err
295 }
296
297 // LineItems okay. Remove, free memory, and compare rest of proto.
298 want.Item = nil
299 got.Item = nil
300 if diff := cmp.Diff(got, want, cmp.Comparer(proto.Equal)); diff != "" {
301 return fmt.Errorf("order protos differ. LineItems excluded, already validated. (-got +want)\n%s", diff)
302 }
303
304 return nil
305}
306
307// ValidateOrders performs simple comparisons and set up before forwarding orders
308// individually to ValidateOrder.
309func ValidateOrders(got, want Orders) error {
310 if len(got) != len(want) {
311 return fmt.Errorf("number of Orders differ got %d want %d", len(got), len(want))
312 }
313 sort.Sort(got)
314 sort.Sort(want)
315
316 var errorStrings []string
317 for i := 0; i < len(got); i++ {
318 if err := ValidateOrder(*got[i], *want[i]); err != nil {
319 errorStrings = append(errorStrings, err.Error())
320 }
321 }
322
323 if len(errorStrings) != 0 {
324 return errors.New(strings.Join(errorStrings, "\n"))
325 }
326 return nil
327}
328
329func hashLineItemByTicketIds(l *mpb.LineItem) string {
330 var uID []string
331 for _, ticket := range l.GetTickets() {
332 uID = append(uID, ticket.GetTicketId())
333 }
334 return fmt.Sprintf("%x", md5.Sum([]byte(strings.Join(uID, `|`))))
335}
336
337// Orders is a convenience type for an Orders array
338type Orders []*mpb.Order
339
340func (o Orders) Len() int {
341 return len(o)
342}
343
344func (o Orders) Less(i, j int) bool {
345 return o[i].GetOrderId() < o[j].GetOrderId()
346}
347
348func (o Orders) Swap(i, j int) {
349 o[i], o[j] = o[j], o[i]
350}
351
352// OrderedTickets is a convenience type for an OrderedTickets array
353type OrderedTickets []*mpb.LineItem_OrderedTickets
354
355func (ot OrderedTickets) Len() int {
356 return len(ot)
357}
358
359func (ot OrderedTickets) Less(i, j int) bool {
360 return ot[i].GetTicketId() < ot[j].GetTicketId()
361}
362
363func (ot OrderedTickets) Swap(i, j int) {
364 ot[i], ot[j] = ot[j], ot[i]
365}
366
367// LineItems is a convenience type for a LineItem array
368// This sort interface defined below should be used iff
369// OrderedTickets have already been sorted AND
370// ticket ids are UIDs
371type LineItems []*mpb.LineItem
372
373func (l LineItems) Len() int {
374 return len(l)
375}
376
377func (l LineItems) Less(i, j int) bool {
378 return hashLineItemByTicketIds(l[i]) < hashLineItemByTicketIds(l[j])
379}
380
381func (l LineItems) Swap(i, j int) {
382 l[i], l[j] = l[j], l[i]
383}
384
Will Silberman1484aa42018-03-23 15:25:40 -0700385// BuildSlotFrom creates a bookingservice slot from an feed availability record.
386func BuildSlotFrom(a *fpb.Availability) (*mpb.Slot, error) {
387 r := a.GetResources()
388 return &mpb.Slot{
389 MerchantId: a.GetMerchantId(),
390 ServiceId: a.GetServiceId(),
391 StartSec: a.GetStartSec(),
392 DurationSec: a.GetDurationSec(),
393 AvailabilityTag: a.GetAvailabilityTag(),
394 Resources: &mpb.ResourceIds{
395 StaffId: r.GetStaffId(),
396 RoomId: r.GetRoomId(),
397 PartySize: r.GetPartySize(),
398 },
399 }, nil
400}
401
402// BuildMerchantServiceMap creates a key value pair of unique services to all of their availability slots.
403func BuildMerchantServiceMap(av []*fpb.Availability) map[SlotKey][]*fpb.Availability {
404 m := make(map[SlotKey][]*fpb.Availability)
405 for _, a := range av {
406 key := SlotKey{
407 MerchantID: a.GetMerchantId(),
408 ServiceID: a.GetServiceId(),
409 StaffID: a.GetResources().GetStaffId(),
410 RoomID: a.GetResources().GetRoomId(),
411 }
412 m[key] = append(m[key], a)
413 }
414 return m
415}