package models import ( "reflect" "strings" "testing" ) // Table names and column tags are strings the compiler never checks. A typo in // either produces a binary that builds cleanly and then fails against the // database at runtime — which, for the customer app, means the first real // booking. These are cheap tests for a class of bug nothing else catches. // Every customer-app model maps to the table AutoMigrate creates and the seed // fills. A mismatch here means seed_customer_app.sql populates one table while // the code reads another. func TestCustomerAppTableNames(t *testing.T) { cases := []struct { model interface{ TableName() string } want string }{ {ServiceableState{}, "serviceablestates"}, {ServiceableDistrict{}, "serviceabledistricts"}, {PickupSlotTemplate{}, "pickupslottemplates"}, {CustomerBookingLimit{}, "customerbookinglimits"}, {BookingDestination{}, "bookingdestinations"}, {BookingParcelPhoto{}, "bookingparcelphotos"}, {BookingStageEvent{}, "bookingstageevents"}, {CustomerRefreshToken{}, "customerrefreshtokens"}, {CustomerDevice{}, "customerdevices"}, } for _, tc := range cases { if got := tc.model.TableName(); got != tc.want { t.Errorf("%T.TableName() = %q, want %q", tc.model, got, tc.want) } } } // columnOf reads the gorm column name off a struct field, the way the driver // does. func columnOf(t *testing.T, model interface{}, field string) string { t.Helper() f, ok := reflect.TypeOf(model).FieldByName(field) if !ok { t.Fatalf("%T has no field %s", model, field) } for _, part := range strings.Split(f.Tag.Get("gorm"), ";") { if strings.HasPrefix(part, "column:") { return strings.TrimPrefix(part, "column:") } } return "" } // The columns the fan-out is built on. bookingdestinations.seq in particular is // addressed by position in PATCH .../destinations/{index} and is what stop // ordering sorts by — a renamed column there silently reorders a rider's route. func TestBookingDestinationColumns(t *testing.T) { want := map[string]string{ "Bookingdestinationid": "bookingdestinationid", "Bookingid": "bookingid", "Seq": "seq", "Statecode": "statecode", "Districtcode": "districtcode", "Packagecount": "packagecount", "Recipientname": "recipientname", "Recipientphone": "recipientphone", "Codamount": "codamount", "Consignmentid": "consignmentid", "Trackingno": "trackingno", "Stage": "stage", "Verifiedweightkg": "verifiedweightkg", } for field, col := range want { if got := columnOf(t, BookingDestination{}, field); got != col { t.Errorf("BookingDestination.%s column = %q, want %q", field, got, col) } } } // The columns added to the live pickupbookings table. These land on 512 // existing rows, so the names have to match what migrate.go creates. func TestPickupBookingCustomerColumns(t *testing.T) { want := map[string]string{ "Slotid": "slotid", "Customerstage": "customerstage", "Customerstatus": "customerstatus", "Estimateminrupees": "estimateminrupees", "Estimatemaxrupees": "estimatemaxrupees", "Routekm": "routekm", "Pickuptitle": "pickuptitle", "Pickupsub": "pickupsub", "Cancelreason": "cancelreason", } for field, col := range want { if got := columnOf(t, PickupBooking{}, field); got != col { t.Errorf("PickupBooking.%s column = %q, want %q", field, got, col) } } // The link that lets a parcel know which order it belongs to. if got := columnOf(t, BookingParcel{}, "Bookingdestinationid"); got != "bookingdestinationid" { t.Errorf("BookingParcel.Bookingdestinationid column = %q, want %q", got, "bookingdestinationid") } } // A refresh token is a bearer credential valid for sixty days. It must never be // serialised outward — a JSON tag of anything but "-" would put it in a // response body. func TestRefreshTokenHashIsNeverSerialised(t *testing.T) { f, ok := reflect.TypeOf(CustomerRefreshToken{}).FieldByName("Tokenhash") if !ok { t.Fatal("CustomerRefreshToken has no Tokenhash field") } if got := f.Tag.Get("json"); got != "-" { t.Errorf("Tokenhash json tag = %q, want \"-\" — a stored credential must "+ "never leave the server", got) } } // The delivery OTP is the receiver's proof, not the rider's. Returning it in a // response would hand the rider the code they are supposed to be told at the // door. func TestDeliveryOtpIsNeverSerialised(t *testing.T) { f, ok := reflect.TypeOf(Consignment{}).FieldByName("Deliveryotp") if !ok { t.Fatal("Consignment has no Deliveryotp field") } if got := f.Tag.Get("json"); got != "-" { t.Errorf("Deliveryotp json tag = %q, want \"-\"", got) } } // The customer PIN hash predates the OTP flow and is now unused, but the column // still exists. It must stay unserialised. func TestCustomerPinHashIsNeverSerialised(t *testing.T) { f, ok := reflect.TypeOf(AppCustomer{}).FieldByName("Loginpinhash") if !ok { t.Fatal("AppCustomer has no Loginpinhash field") } if got := f.Tag.Get("json"); got != "-" { t.Errorf("Loginpinhash json tag = %q, want \"-\"", got) } } // Two columns on pickupbookings that are computed, not stored. A missing // gorm:"-" would make AutoMigrate create real columns for them and the driver // would try to write there. func TestComputedBookingFieldsAreNotColumns(t *testing.T) { for _, field := range []string{"Consignmentstatus", "Trackingno"} { f, ok := reflect.TypeOf(PickupBooking{}).FieldByName(field) if !ok { t.Fatalf("PickupBooking has no field %s", field) } if got := f.Tag.Get("gorm"); got != "-" { t.Errorf("PickupBooking.%s gorm tag = %q, want \"-\" — it is filled in "+ "by handlers, not stored", field, got) } } } // The device token is unique across the whole table, not per customer: a shared // handset must be REASSIGNED rather than duplicated, or one person's parcel // updates reach another person's phone. func TestCustomerDeviceTokenIsGloballyUnique(t *testing.T) { f, _ := reflect.TypeOf(CustomerDevice{}).FieldByName("Token") if tag := f.Tag.Get("gorm"); !strings.Contains(tag, "uniqueIndex") { t.Errorf("CustomerDevice.Token gorm tag = %q, want a uniqueIndex — the "+ "upsert on conflict depends on it", tag) } }