mirror of
https://github.com/rjNemo/rentease.git
synced 2026-06-12 13:46:51 +00:00
feat(booking): improve item sync and add tests (#51)
Some checks failed
CI / checks (push) Has been cancelled
Some checks failed
CI / checks (push) Has been cancelled
Enhance the booking sync logic to trim and match item names more robustly, falling back to creating a generic item when no host item is found. Add unit tests for item creation and fallback behavior in booking sync.
This commit is contained in:
parent
d75533d431
commit
be5b707fa0
2 changed files with 339 additions and 3 deletions
|
|
@ -1,6 +1,9 @@
|
||||||
package booking
|
package booking
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/rjNemo/rentease/internal/config"
|
"github.com/rjNemo/rentease/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,14 +17,88 @@ func (bs Service) ParseFromAPI(rawContent string) (*Booking, error) {
|
||||||
b = bs.Create(b.From, b.To, b.Name, b.PhoneNumber, b.Email, string(b.Platform), b.CustomerNumber, b.PlatformFees, b.ExternalID)
|
b = bs.Create(b.From, b.To, b.Name, b.PhoneNumber, b.Email, string(b.Platform), b.CustomerNumber, b.PlatformFees, b.ExternalID)
|
||||||
|
|
||||||
hostItems := config.NewHost().Items
|
hostItems := config.NewHost().Items
|
||||||
|
createdItems := make([]Item, 0, len(items))
|
||||||
for _, itm := range items {
|
for _, itm := range items {
|
||||||
hostItem, ok := hostItems[itm.Item]
|
itemName := strings.TrimSpace(itm.Item)
|
||||||
if !ok {
|
hostItem, ok := findHostItem(hostItems, itemName)
|
||||||
|
if ok {
|
||||||
|
for _, created := range bs.CreateItem(b.ID, hostItem, itm.Quantity, itm.Price, itm.PaymentMethod, b.CustomerNumber, string(b.Platform)) {
|
||||||
|
createdItems = append(createdItems, *created)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
bs.CreateItem(b.ID, hostItem, itm.Quantity, itm.Price, itm.PaymentMethod, b.CustomerNumber, string(b.Platform))
|
fallbackItem := &Item{
|
||||||
|
BookingID: b.ID,
|
||||||
|
Item: itemName,
|
||||||
|
Quantity: itm.Quantity,
|
||||||
|
Price: itm.Price,
|
||||||
|
PaymentMethod: itm.PaymentMethod,
|
||||||
|
PaymentStatus: "Pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bs.store.CreateItem(fallbackItem); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create item %q for booking %d: %w", itemName, b.ID, err)
|
||||||
|
}
|
||||||
|
createdItems = append(createdItems, *fallbackItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
b.Items = createdItems
|
||||||
|
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findHostItem(hostItems map[string]config.HostItem, itemName string) (config.HostItem, bool) {
|
||||||
|
if itemName == "" {
|
||||||
|
return config.HostItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact match on key
|
||||||
|
if hostItem, ok := hostItems[itemName]; ok {
|
||||||
|
return hostItem, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive match on key
|
||||||
|
for key, hostItem := range hostItems {
|
||||||
|
if strings.EqualFold(key, itemName) {
|
||||||
|
return hostItem, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token match (handles verbose names containing the configured item, e.g., "One-Bedroom House (T2 - ...)")
|
||||||
|
tokens := tokenizeItemName(itemName)
|
||||||
|
for key, hostItem := range hostItems {
|
||||||
|
lKey := strings.ToLower(key)
|
||||||
|
if tokens[lKey] || tokens[strings.ToLower(hostItem.Name)] {
|
||||||
|
return hostItem, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substring match as last resort (avoids dropping partially matching items)
|
||||||
|
lower := strings.ToLower(itemName)
|
||||||
|
for key, hostItem := range hostItems {
|
||||||
|
lKey := strings.ToLower(key)
|
||||||
|
lName := strings.ToLower(hostItem.Name)
|
||||||
|
if strings.Contains(lower, lKey) || strings.Contains(lower, lName) {
|
||||||
|
return hostItem, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.HostItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenizeItemName(itemName string) map[string]bool {
|
||||||
|
tokens := strings.FieldsFunc(itemName, func(r rune) bool {
|
||||||
|
return r == ' ' || r == '-' || r == '_' || r == '(' || r == ')' || r == ',' || r == '/' || r == '.'
|
||||||
|
})
|
||||||
|
|
||||||
|
result := make(map[string]bool, len(tokens))
|
||||||
|
for _, t := range tokens {
|
||||||
|
if t == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[strings.ToLower(t)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
|
||||||
259
internal/service/booking/sync_test.go
Normal file
259
internal/service/booking/sync_test.go
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
package booking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rjNemo/rentease/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseFromAPI_CreatesItemsForBookingSync(t *testing.T) {
|
||||||
|
store := newStubStore()
|
||||||
|
parser := stubParser{
|
||||||
|
booking: Booking{
|
||||||
|
From: time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
To: time.Date(2025, time.January, 4, 0, 0, 0, 0, time.UTC),
|
||||||
|
Name: "Jane Doe",
|
||||||
|
PhoneNumber: "123456",
|
||||||
|
Email: "jane@example.com",
|
||||||
|
Platform: config.Platform("Booking"),
|
||||||
|
CustomerNumber: 2,
|
||||||
|
PlatformFees: 15.0,
|
||||||
|
Items: []Item{{
|
||||||
|
Item: " T3 ",
|
||||||
|
Quantity: 3,
|
||||||
|
Price: 80.0,
|
||||||
|
PaymentMethod: "Card",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := NewService(slog.Default(), store, parser, noopPDF{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error creating service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
booking, err := svc.ParseFromAPI("raw booking content")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error parsing booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(store.items) != 2 {
|
||||||
|
t.Fatalf("expected 2 items to be created (booking item + taxes), got %d", len(store.items))
|
||||||
|
}
|
||||||
|
|
||||||
|
if store.items[0].Item != "T3" {
|
||||||
|
t.Fatalf("expected base item to be trimmed to host item name, got %q", store.items[0].Item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if store.items[1].Item != "Taxes" {
|
||||||
|
t.Fatalf("expected taxes item to be created, got %q", store.items[1].Item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(booking.Items) != 2 {
|
||||||
|
t.Fatalf("expected booking to include created items, got %d", len(booking.Items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFromAPI_DoesNotCreateTaxesForNonBookingPlatform(t *testing.T) {
|
||||||
|
store := newStubStore()
|
||||||
|
parser := stubParser{
|
||||||
|
booking: Booking{
|
||||||
|
From: time.Date(2025, time.March, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
To: time.Date(2025, time.March, 3, 0, 0, 0, 0, time.UTC),
|
||||||
|
Name: "Alex Smith",
|
||||||
|
PhoneNumber: "987654",
|
||||||
|
Email: "alex@example.com",
|
||||||
|
Platform: config.Platform("AirBnb"),
|
||||||
|
CustomerNumber: 2,
|
||||||
|
Items: []Item{{
|
||||||
|
Item: "t2",
|
||||||
|
Quantity: 2,
|
||||||
|
Price: 59.0,
|
||||||
|
PaymentMethod: "Card",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := NewService(slog.Default(), store, parser, noopPDF{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error creating service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.ParseFromAPI("raw booking content"); err != nil {
|
||||||
|
t.Fatalf("unexpected error parsing booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(store.items) != 1 {
|
||||||
|
t.Fatalf("expected only base item without taxes for non-Booking platform, got %d items", len(store.items))
|
||||||
|
}
|
||||||
|
if store.items[0].Item != "T2" {
|
||||||
|
t.Fatalf("expected base item to match host item name, got %q", store.items[0].Item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFromAPI_CreatesFallbackItemWhenUnknown(t *testing.T) {
|
||||||
|
store := newStubStore()
|
||||||
|
parser := stubParser{
|
||||||
|
booking: Booking{
|
||||||
|
From: time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
To: time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC),
|
||||||
|
Name: "Jane Doe",
|
||||||
|
PhoneNumber: "123456",
|
||||||
|
Email: "jane@example.com",
|
||||||
|
Platform: config.Platform("Booking"),
|
||||||
|
CustomerNumber: 1,
|
||||||
|
Items: []Item{{
|
||||||
|
Item: "New Suite",
|
||||||
|
Quantity: 1,
|
||||||
|
Price: 120.0,
|
||||||
|
PaymentMethod: "Card",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := NewService(slog.Default(), store, parser, noopPDF{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error creating service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.ParseFromAPI("raw booking content"); err != nil {
|
||||||
|
t.Fatalf("unexpected error parsing booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(store.items) != 1 {
|
||||||
|
t.Fatalf("expected fallback item to be created, got %d items", len(store.items))
|
||||||
|
}
|
||||||
|
|
||||||
|
if store.items[0].Item != "New Suite" {
|
||||||
|
t.Fatalf("expected fallback item name to match parsed value, got %q", store.items[0].Item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFromAPI_NormalizesVerboseItemNameToHostItem(t *testing.T) {
|
||||||
|
store := newStubStore()
|
||||||
|
parser := stubParser{
|
||||||
|
booking: Booking{
|
||||||
|
From: time.Date(2025, time.April, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
To: time.Date(2025, time.April, 3, 0, 0, 0, 0, time.UTC),
|
||||||
|
Name: "Chris P Bacon",
|
||||||
|
PhoneNumber: "999999",
|
||||||
|
Email: "chris@example.com",
|
||||||
|
Platform: config.Platform("Booking"),
|
||||||
|
CustomerNumber: 2,
|
||||||
|
Items: []Item{{
|
||||||
|
Item: "One-Bedroom House (T2 - VillaFleurie au bourg du Gosier)",
|
||||||
|
Quantity: 2,
|
||||||
|
Price: 59.0,
|
||||||
|
PaymentMethod: "Card",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := NewService(slog.Default(), store, parser, noopPDF{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error creating service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.ParseFromAPI("raw booking content"); err != nil {
|
||||||
|
t.Fatalf("unexpected error parsing booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(store.items) != 2 {
|
||||||
|
t.Fatalf("expected base item plus taxes, got %d items", len(store.items))
|
||||||
|
}
|
||||||
|
|
||||||
|
if store.items[0].Item != "T2" {
|
||||||
|
t.Fatalf("expected verbose item name to normalize to host item 'T2', got %q", store.items[0].Item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubParser struct {
|
||||||
|
booking Booking
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p stubParser) Parse(rawContent string) (*Booking, error) {
|
||||||
|
if p.err != nil {
|
||||||
|
return nil, p.err
|
||||||
|
}
|
||||||
|
|
||||||
|
cp := p.booking
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubStore struct {
|
||||||
|
bookings []*Booking
|
||||||
|
items []*Item
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStubStore() *stubStore {
|
||||||
|
return &stubStore{
|
||||||
|
bookings: make([]*Booking, 0),
|
||||||
|
items: make([]*Item, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) All() []*Line {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) Search(value string) []*Line {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) List(from, to time.Time) ([]*Line, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) CardTotal(from, to time.Time) (float64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) Get(id int) (*Booking, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) Create(b *Booking) error {
|
||||||
|
b.ID = len(s.bookings) + 1
|
||||||
|
s.bookings = append(s.bookings, b)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) Update(b *Booking) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) Cancel(id int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) CreateItem(i *Item) error {
|
||||||
|
i.ID = len(s.items) + 1
|
||||||
|
s.items = append(s.items, i)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) PayItem(id int) (*Item, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) GetItem(id int) (*Item, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) UpdateItem(id int, item string, paymentMethod string, paymentStatus string, qty int, price float64) (*Item, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopPDF struct{}
|
||||||
|
|
||||||
|
func (noopPDF) BuildInvoice(invoice Invoice) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (noopPDF) BuildReport(report ReportData, period string, month, year int) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue