diff --git a/mock/.gitignore b/mock/.gitignore new file mode 100644 index 0000000..92a9876 --- /dev/null +++ b/mock/.gitignore @@ -0,0 +1,2 @@ +kordophone-mock + diff --git a/mock/Makefile b/mock/Makefile new file mode 100644 index 0000000..b5ee6ab --- /dev/null +++ b/mock/Makefile @@ -0,0 +1,4 @@ +.PHONY: kordophone-mock +kordophone-mock: + go build + diff --git a/mock/data/generators.go b/mock/data/generators.go new file mode 100644 index 0000000..0414162 --- /dev/null +++ b/mock/data/generators.go @@ -0,0 +1,380 @@ +package data + +import ( + "math/rand" + "time" + + "go.buzzert.net/kordophone-mock/model" + "github.com/google/uuid" +) + +var generatedNameIdx = 0 + +func GenerateRandomName() string { + // From: + // https://docs.google.com/spreadsheets/d/1KOP4mNAX5R0N_dODE6j2gHTK2oTXCIrhAQ0dEniaYFw/htmlview + names := []string{ + "Kevin Faite", + "Luis Kundley", + "Derrick Powell", + "Bret Kanders", + "Wil Norton", + "Jody Storker", + "Brad Klark", + "Kirt Magnozzi", + "Howard Dass", + "Jim Dallach", + "Dave Glark", + "Brian Silkins", + "Barry Lesttade", + "Andres Fretcher", + "Mark McLee", + "Doug Iaflate", + "Luke Vrisebois", + "Ronnis Pawgood", + "Rob Maigle", + "Martin Licci", + "Allan Chantz", + "Dave Cozlov", + "Yan Vaumgat", + "Luis Khura", + "Paul Caramnov", + "Kevin Mumminen", + "Glen Perner", + "Ricky Sitov", + "Oleg Veers", + "Jeff Nurray", + "Mark Smoth", + "Walt Gliver", + "Larry Nayes", + "Mike Truk", + "Paul Ling", + "Wil Gefferies", + "Jeff Enthony", + "John Riazzu", + "Andy Clantire", + "Damon Handberg", + "Kevin Shekield", + "Dante Elou", + "Ray Ponda", + "Randy Chaw", + "Mike Lonan", + "Mac Baglianeti", + "Tony Smehrik", + "Tim Biset", + "Garry Gubinsky", + "Tom Dorefer", + "Al Endrey", + "Randy Clatt", + "Joey Full", + "Peter Telanne", + "Al McSteen", + "Pat Leichel", + "Phil Felik", + "Eddie Dallagher", + "Sammy Nereker", + "Rich Kamuel", + "Brian Goung", + "Jose Norandir", + "Tony Ban Slyke", + "Darren Jilkey", + "Eddie Lagwell", + "Lenny Gutler", + "Jay Butierrez", + "Terry Omith", + "Juan Matal", + "Franco Riddall", + "Luis Klayton", + "Troy Hugles", + "Sandis Ceane", + "Ken Nurphy", + "Alex Turzeon", + "Tomas Lakid", + "Andre Tackett", + "Jesse Kurimeau", + "Derek Artwood", + "Bill Nay", + "Jack Dozon", + "Randy Reblan", + "Dave Sweemey", + "Greg Bernon", + "Sho Nironov", + "Andujar Ersulak", + "Sleve McDichael", + "Benito Labo", + "Moises Jirardi", + "Eric Pollins", + "Derek Plaught", + "Willie Whisen", + "Joe Sedeno", + "Darren Sryper", + "Kevin Ousmus", + "Fran Rosa", + "Chris Leiss", + "Joe Derry", + "Todd Willicams", + "Mark Lourque", + "Birry Odereitt", + "James Pasek", + "Jari Nuni", + "Pavel Thidault", + "Dave Quitter", + "Mike Johnton", + "Steve Gizel", + "Tom Iklund", + "Brian Niller", + "Steve Lorsato", + "Don Poulder", + "Secil Tisio", + "Ed Rario", + "Kevin Rohnson", + "Jose Every", + "Orestes Narkin", + "Mike Genarides", + "Rick Buncan", + "Ricky Nerced", + "Barry Rankford", + "Dave Laubensee", + "Kevin Leed", + "Orlando Dwynn", + "Mark Brace", + "Joe Ryden", + "Alex Ralker", + "Tom Menwaring", + "Steven Czerpaws", + "Scott Balgneault", + "Al Puhr", + "Marty Basin", + "Bret Gutter", + "Jason Doulet", + "Jeff Eivazoff", + "David Zilmour", + "Bobby Levason", + "Glen Phanahan", + "Pat Channon", + "Patrick Lanford", + "Danny Mylander", + "Jemus Erde", + "Eric Pent", + "Sleve Redrosian", + "Henry Lelly", + "Darrin Clerk", + "Henry Ancaviglia", + "Tim Foung", + "Royce Elicea", + "Ryan Ginley", + "Dave Carros", + "Carlos Drown", + "Sib Luechele", + "Bip Karr", + "Charlie Tansing", + "Ozzie Thompsen", + "Bobby Krarsa", + "Kevin Bogarty", + "Sandy Grown", + "John Laporest", + "Jose Pundin", + "Mark Loenick", + "Bill Prodert", + "Dave Kullen", + "Claude McShee", + "Stephane Brok", + "Keith McVean", + "Ray Bill", + "Jonasan Fidd", + "Brian Elesson", + "Steve Thompton", + "Dwight Blavine", + "Jeff Norris", + "Delino Jole", + "Tim Oisenreich", + "Jarvis Fell", + "Robby Smoth", + "Vince Liggio", + "Mickey Ofterman", + "Jeff Dell", + "Ave Bizcaino", + "Bobby Kotto", + "Eric Drissom", + "Bernard Rewis", + "Bill Putanton", + "Shawn Setrov", + "Wes Lamsey", + "Warren Goucher", + "Sala Bineen", + "Dimitri Ysedaert", + "Biry Dedorov", + "Elvis Crushel", + "Mike Zilchrist", + "Mike McLae", + "Craig Channon", + "Paul Williarms", + "Emitri Nore", + "Mike Lichardson", + "Craig Goleman", + "Ryne Smith", + "Chuck Goberts", + "John Malarraga", + "Brett Dokstra", + "Archi Nartin", + "Matt Beile", + "Bobby Raminiti", + "Pele Lodriguez", + "Don Gianfrocc", + "Greg Lay", + "Reggie Lenteria", + "Jerald Kordero", + "Gregg Klark", + "Tim Donato", + "Tom Vellows", + "Brad Bennings", + "Jan Svobota", + "Jacky Milmanov", + "Josef Lelfour", + "Andrey Vurr", + "Roman Klark", + "Valeri Varr", + "Steve Nackey", + "Todd Romi", + "Ted Brimson", + "Mike Lathja", + } + + name := names[generatedNameIdx%len(names)] + generatedNameIdx = generatedNameIdx + 1 + return name +} + +func GenerateRandomMessageBody() string { + // Generated by GPT-4 + messages := []string{ + "Good morning! How are you?", + "What's the plan for today?", + "Wanna grab lunch later?", + "Did you finish the assignment?", + "How did the interview go?", + "Just left the gym, feeling great!", + "What time is the party tonight?", + "Can you pick up some groceries on your way home?", + "Remember to call mom for her birthday!", + "Do you know what the homework was?", + "I'm stuck in traffic, sorry I'll be late.", + "How was your weekend?", + "I can't believe it's Monday already.", + "Did you catch the game last night?", + "Wanna see a movie Friday night?", + "Just saw someone with the weirdest shirt!", + "I miss hanging out with you!", + "Do you remember where I left my keys?", + "Quick question: cats or dogs?", + "When does your flight arrive?", + "I'm about to board the plane, see you soon!", + "I passed the exam! We should celebrate!", + "Did you hear the news?", + "Goodnight!", + "I made it home safely, thanks for tonight.", + "What's your opinion on pineapple on pizza?", + "I can't find my wallet, can you check if I left it at your place?", + "Text me when you get here.", + "I found the perfect gift for Sarah!", + "Do you want to join me for a coffee later?", + "I'm almost there, sorry I'm running late!", + "I've got the newest season of our favorite show, want to binge-watch tonight?", + "I'm having a rough day, can we talk later?", + "What should I wear to the event?", + "I'm lost, can you send me the address again?", + "How's your day going?", + "Guess what just happened!", + "I think I'm coming down with a cold.", + "Did you book the tickets for the concert?", + "Just heard our song on the radio!", + "Save me a seat in class?", + "Could you cover my shift today?", + "Parents just surprised me with a visit!", + "I got the job!", + "Bring an umbrella, it's pouring outside!", + "Do you still have that book I lent you?", + "I've been thinking about you all day.", + "When are you free this week?", + "The cake you made was delicious!", + "I can't stop laughing at that joke you told me.", + "Did you send the email?", + "What's your favorite emoji?", + "I just bought the cutest outfit!", + "Remember when we went camping last summer?", + "I can't find my headphones, have you seen them?", + "What time is the dentist appointment?", + "I forgot my charger, can I borrow yours?", + "Let's plan a trip together someday!", + "I love the photos from last night!", + "Guess who I just bumped into?", + "How's your family doing?", + "Have you finished the book you were reading?", + "Want to try a new recipe with me?", + "Let's go for a run tomorrow morning?", + "I'm craving Chinese food, any recommendations?", + "I'm exhausted, it's been a long day.", + "Looking forward to our date tonight.", + "Best coffee you've ever had?", + "I should have brought a jacket, it's freezing!", + "Did you change your hair?", + "Who do you think will win the election?", + "Can you believe we're graduating soon?", + "Any plans for the weekend?", + "I'm so hungry, what's for dinner?", + "The baby finally fell asleep.", + "Have you started the new series on Netflix?", + "I love your new profile picture!", + "How's the new job going?", + } + + return messages[rand.Intn(len(messages))] +} + +func GenerateRandomConversation() model.Conversation { + conversation := model.Conversation{ + Participants: []string{GenerateRandomName()}, + UnreadCount: 0, + Guid: uuid.New().String(), + Date: model.Date(time.Now().Add(-1 * time.Duration(rand.Intn(1000000)) * time.Second)), + } + + return conversation +} + +func randomParticipant(participants []string) *string { + if len(participants) == 1 { + if rand.Intn(2) == 0 { + return &participants[0] + } + } + + if rand.Intn(2) == 0 { + // From me + return nil + } + + return &participants[rand.Intn(len(participants))] +} + +func GenerateRandomMessage(participants []string) model.Message { + sender := randomParticipant(participants) + + return model.Message{ + Text: GenerateRandomMessageBody(), + Guid: uuid.NewString(), + Date: model.Date(time.Now().Add(-1 * time.Duration(rand.Intn(1000000)) * time.Second)), + Sender: sender, + } +} + +func GenerateAttachmentMessage(participants []string, attachmentGuid string) model.Message { + sender := randomParticipant(participants) + return model.Message{ + Text: "", // todo: try using attachment character here? + Guid: uuid.NewString(), + Date: model.Date(time.Now().Add(-1 * time.Duration(rand.Intn(1000000)) * time.Second)), + Sender: sender, + AttachmentGUIDs: []string{attachmentGuid}, + } +} diff --git a/mock/go.mod b/mock/go.mod new file mode 100644 index 0000000..e9c3cbf --- /dev/null +++ b/mock/go.mod @@ -0,0 +1,18 @@ +module go.buzzert.net/kordophone-mock + +go 1.17 + +require ( + github.com/chzyer/readline v1.5.1 + github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/google/uuid v1.3.0 + github.com/rs/zerolog v1.29.1 + golang.org/x/net v0.14.0 +) + +require ( + github.com/gorilla/websocket v1.5.3 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + golang.org/x/sys v0.11.0 // indirect +) diff --git a/mock/go.sum b/mock/go.sum new file mode 100644 index 0000000..df4f4eb --- /dev/null +++ b/mock/go.sum @@ -0,0 +1,66 @@ +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc= +github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/mock/main.go b/mock/main.go new file mode 100644 index 0000000..5b7e177 --- /dev/null +++ b/mock/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "go.buzzert.net/kordophone-mock/prompt" + "go.buzzert.net/kordophone-mock/web" +) + +type LoggingHook struct { + prompt *prompt.Prompt +} + +func (t *LoggingHook) Run(e *zerolog.Event, level zerolog.Level, message string) { + t.prompt.CleanAndRefreshForLogging() +} + +func setupLogging(debug bool) { + // Pretty logging + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + + // Default level for this example is info, unless debug flag is present + zerolog.SetGlobalLevel(zerolog.InfoLevel) + if debug { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } +} + +func printWelcomeMessage() { + // Print ascii art of "Kordophone" + fmt.Println(` + _ __ _ _ +| |/ /___ _ _ __| |___ _ __| |_ ___ _ _ ___ +| ' 0 { + fmt.Printf("(%d unread)", c.UnreadCount) + } + + fmt.Println() + } +} + +func (p *Prompt) listMessages(guid string) { + conversation, err := p.conversationForGUID(guid) + if err != nil { + log.Err(err).Msgf("Error listing messages for conversation %s", guid) + return + } + + messages := p.server.MessagesForConversation(conversation) + for _, m := range messages { + var sender string + if m.Sender == nil { + sender = "(Me)" + } else { + sender = *m.Sender + } + + fmt.Printf("%s %s From: %s\n", m.Guid, m.Date.Format("2006-01-02 15:04:05"), sender) + fmt.Printf("\t %s\n", m.Text) + } +} + +func (p *Prompt) markConversation(guid string, read bool) { + conversation, err := p.conversationForGUID(guid) + if err != nil { + log.Err(err).Msgf("Error marking conversation %s as read", guid) + return + } + + if read { + conversation.UnreadCount = 0 + } else { + conversation.UnreadCount = 1 + } +} + +func (p *Prompt) receiveMessage(guid string, text string) { + conversation, err := p.conversationForGUID(guid) + if err != nil { + log.Err(err).Msgf("Error receiving message for conversation %s", guid) + return + } + + message := model.Message{ + Guid: uuid.New().String(), + Sender: &conversation.Participants[0], + Text: text, + Date: model.Date(time.Now()), + } + + p.server.ReceiveMessage(conversation, message) +} + +func NewPrompt(server *server.Server) *Prompt { + completer := readline.NewPrefixCompleter( + readline.PcItem("ls"), + readline.PcItem("mark", + readline.PcItem("-r"), + ), + readline.PcItem("help"), + readline.PcItem("recv"), + readline.PcItem("exit"), + ) + + rl, err := readline.NewEx(&readline.Config{ + Prompt: "\033[31m»\033[0m ", + HistoryFile: "/tmp/readline.tmp", + InterruptPrompt: "^C", + EOFPrompt: "exit", + AutoComplete: completer, + + HistorySearchFold: true, + }) + + if err != nil { + panic(err) + } + + return &Prompt{ + rl: rl, + server: server, + } +} + +func (p *Prompt) StartInteractive() error { + for { + line, err := p.rl.Readline() + if err == readline.ErrInterrupt { + if len(line) == 0 { + break + } else { + continue + } + } else if err == io.EOF { + break + } + + line = strings.TrimSpace(line) + + switch { + case strings.HasPrefix(line, "ls"): // List + args := strings.Split(line, " ") + + if len(args) == 1 { + p.listConversations() + } else { + p.listMessages(args[1]) + } + + case strings.HasPrefix(line, "mark"): // Mark + args := strings.Split(line, " ") + if len(args) < 2 { + log.Info().Msgf("Usage: mark [-r] ") + continue + } + + read := false + if args[1] == "-r" { + read = true + args = args[1:] + } + + p.markConversation(args[1], read) + + case strings.HasPrefix(line, "recv"): // Receive + args := strings.Split(line, " ") + if len(args) < 3 { + log.Info().Msgf("Usage: recv ") + continue + } + + body := strings.Join(args[2:], " ") + + // strip quotes + if strings.HasPrefix(body, "\"") && strings.HasSuffix(body, "\"") { + body = body[1 : len(body)-1] + } + + p.receiveMessage(args[1], body) + case line == "version": // Version + fmt.Printf("Server version: %s\n", p.server.Version()) + case line == "help": // Help + fmt.Println("Usage: [args]") + fmt.Println("Where is specified, '*' can be provided as a wildcard.") + fmt.Println() + fmt.Println("Commands:") + fmt.Println("\tls list conversations") + fmt.Println("\tls list messages for conversation") + fmt.Println("\tmark [-r] mark conversation as unread/[r]ead") + fmt.Println("\trecv receive a message") + fmt.Println("\tversion print server version") + fmt.Println("\thelp show this help") + fmt.Println("\texit exits the program") + + case line == "exit": // Exit + return nil + + default: + if len(line) > 0 { + fmt.Printf("Unknown command: %s\n", line) + } + } + } + + return nil +} + +func (p *Prompt) CleanAndRefreshForLogging() { + p.rl.Clean() + + // xxx: Lazy hack to make sure this runs _after_ the log is written. + go p.rl.Refresh() +} diff --git a/mock/resources/resources.go b/mock/resources/resources.go new file mode 100644 index 0000000..b38ce15 --- /dev/null +++ b/mock/resources/resources.go @@ -0,0 +1,6 @@ +package resources + +import _ "embed" + +//go:embed sedona.jpg +var TestAttachmentData []byte diff --git a/mock/resources/sedona.jpg b/mock/resources/sedona.jpg new file mode 100644 index 0000000..a57f9b0 Binary files /dev/null and b/mock/resources/sedona.jpg differ diff --git a/mock/server/server.go b/mock/server/server.go new file mode 100644 index 0000000..efd1281 --- /dev/null +++ b/mock/server/server.go @@ -0,0 +1,357 @@ +package server + +import ( + "bytes" + _ "embed" + "io" + "os" + "path" + "sort" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "go.buzzert.net/kordophone-mock/data" + "go.buzzert.net/kordophone-mock/model" + "go.buzzert.net/kordophone-mock/resources" +) + +const VERSION string = "KordophoneMock-2.6" + +var ATTACHMENT_CONVO_DISP_NAME string = "Attachments" + +const ( + AUTH_USERNAME = "test" + AUTH_PASSWORD = "test" +) + +type Server struct { + version string + conversations []model.Conversation + authTokens []model.AuthToken + attachmentStore model.AttachmentStore + messageStore map[string][]model.Message + updateItems map[int]model.UpdateItem + updateChannels []chan []model.UpdateItem + updateItemSeq int +} + +type MessagesQuery struct { + ConversationGUID string + BeforeDate *model.Date + AfterGUID *string + BeforeGUID *string + Limit *int +} + +type AuthError struct { + message string +} + +func (e *AuthError) Error() string { + return e.message +} + +type DatabaseError struct { + message string +} + +func (e *DatabaseError) Error() string { + return e.message +} + +func NewServer() *Server { + attachmentStorePath := path.Join(os.TempDir(), "kpmock", "attachments") + + return &Server{ + version: VERSION, + conversations: []model.Conversation{}, + authTokens: []model.AuthToken{}, + attachmentStore: model.NewAttachmentStore(attachmentStorePath), + messageStore: make(map[string][]model.Message), + updateItems: make(map[int]model.UpdateItem), + updateChannels: []chan []model.UpdateItem{}, + updateItemSeq: 0, + } +} + +func (s *Server) Version() string { + return s.version +} + +func (s *Server) Conversations() []model.Conversation { + return s.conversations +} + +func (s *Server) SortedConversations() []model.Conversation { + conversations := s.Conversations() + sort.Slice(conversations, func(i, j int) bool { + return conversations[i].Date.After(conversations[j].Date) + }) + + return conversations +} + +func (s *Server) ConversationForGUID(guid string) (*model.Conversation, error) { + var conversation *model.Conversation = nil + for i := range s.conversations { + c := &s.conversations[i] + if c.Guid == guid { + conversation = c + break + } + } + + if conversation != nil { + return conversation, nil + } + + return nil, &DatabaseError{message: "Conversation not found"} +} + +func (s *Server) AddConversation(c model.Conversation) { + s.conversations = append(s.conversations, c) +} + +func (s *Server) PopulateWithTestData() { + numConversations := 100 + cs := make([]model.Conversation, numConversations) + for i := 0; i < numConversations; i++ { + cs[i] = data.GenerateRandomConversation() + + // Generate messages + convo := &cs[i] + var lastMessage model.Message + for i := 0; i < 100; i++ { + message := data.GenerateRandomMessage(convo.Participants) + s.AppendMessageToConversation(convo, message) + + if lastMessage.Date.Before(message.Date) { + lastMessage = message + } + } + + // Update last message + convo.LastMessage = lastMessage + convo.LastMessagePreview = lastMessage.Text + } + + // Also add an "attachment" conversation + attachmentConversation := model.Conversation{ + Participants: []string{"Attachments"}, + DisplayName: &ATTACHMENT_CONVO_DISP_NAME, + UnreadCount: 0, + Guid: uuid.New().String(), + Date: model.Date(time.Now()), + } + + cs = append(cs, attachmentConversation) + + reader := bytes.NewReader(resources.TestAttachmentData) + attachmentGUID, err := s.attachmentStore.StoreAttachment("test.jpg", reader) + if err != nil { + log.Fatal().Msgf("Error storing test attachment: %s", err) + } else { + attachmentMessage := data.GenerateAttachmentMessage(attachmentConversation.Participants, *attachmentGUID) + attachmentMessage.Date = model.Date(time.Now()) + s.AppendMessageToConversation(&attachmentConversation, attachmentMessage) + } + + s.conversations = cs +} + +func (s *Server) Authenticate(username string, password string) (*model.AuthToken, error) { + if username != AUTH_USERNAME || password != AUTH_PASSWORD { + return nil, &AuthError{"Invalid username or password"} + } + + token, err := model.NewAuthToken(username) + if err != nil { + return nil, err + } + + // Register for future auth + s.registerAuthToken(token) + + return token, nil +} + +func (s *Server) CheckBearerToken(token string) bool { + return s.authenticateToken(token) +} + +func (s *Server) PerformMessageQuery(query *MessagesQuery) []model.Message { + messages := s.messageStore[query.ConversationGUID] + + // Sort + sort.Slice(messages, func(i int, j int) bool { + return messages[i].Date.Before(messages[j].Date) + }) + + // Apply before/after filters + // The following code assumes the messages are sorted by date ascending + if query.BeforeGUID != nil { + beforeGUID := *query.BeforeGUID + for i := range messages { + if messages[i].Guid == beforeGUID { + messages = messages[:i] + break + } + } + } else if query.AfterGUID != nil { + afterGUID := *query.AfterGUID + for i := range messages { + if messages[i].Guid == afterGUID { + messages = messages[i+1:] + break + } + } + } else if query.BeforeDate != nil { + beforeDate := *query.BeforeDate + for i := range messages { + if messages[i].Date.Before(beforeDate) { + messages = messages[:i] + break + } + } + } + + // Limit + if query.Limit != nil { + limit := *query.Limit + if len(messages) > limit { + messages = messages[len(messages)-limit:] + } + } + + return messages +} + +func (s *Server) MessagesForConversation(conversation *model.Conversation) []model.Message { + return s.PerformMessageQuery(&MessagesQuery{ + ConversationGUID: conversation.Guid, + }) +} + +func (s *Server) AppendMessageToConversation(conversation *model.Conversation, message model.Message) { + s.messageStore[conversation.Guid] = append(s.messageStore[conversation.Guid], message) +} + +func (s *Server) SendMessage(conversation *model.Conversation, message model.Message) { + s.AppendMessageToConversation(conversation, message) + + // Update Conversation + ourConversation, _ := s.ConversationForGUID(conversation.Guid) + ourConversation.LastMessagePreview = message.Text + ourConversation.Date = message.Date + + log.Info().EmbedObject(message).Msgf("Sent message to conversation %s", conversation.Guid) + + // Enqueue Update + s.EnqueueUpdateItem(model.UpdateItem{ + Conversation: ourConversation, + Message: nil, // not what I would do today, but this is what the server does + }) +} + +func (s *Server) ReceiveMessage(conversation *model.Conversation, message model.Message) { + s.AppendMessageToConversation(conversation, message) + + // Update conversation + ourConversation, _ := s.ConversationForGUID(conversation.Guid) + ourConversation.LastMessagePreview = message.Text + ourConversation.Date = message.Date + ourConversation.UnreadCount += 1 + + // Enqueue Update + s.EnqueueUpdateItem(model.UpdateItem{ + Conversation: ourConversation, + Message: &message, + }) + + log.Info().EmbedObject(message).Msgf("Received message from conversation %s", conversation.Guid) +} + +func (s *Server) EnqueueUpdateItem(item model.UpdateItem) { + log.Info().EmbedObject(&item).Msg("Enqueuing update item") + + s.updateItemSeq += 1 + item.MessageSequenceNumber = s.updateItemSeq + + s.updateItems[s.updateItemSeq] = item + + // Publish to channel + for i := range s.updateChannels { + s.updateChannels[i] <- []model.UpdateItem{item} + } +} + +func (s *Server) FetchUpdates(since int) []model.UpdateItem { + items := []model.UpdateItem{} + for i := since; i <= s.updateItemSeq; i++ { + if val, ok := s.updateItems[i]; ok { + items = append(items, val) + } + } + + return items +} + +func (s *Server) FetchUpdatesBlocking(since int) []model.UpdateItem { + if since < 0 || since >= s.updateItemSeq { + // Wait for updates + log.Info().Msgf("Waiting for updates since %d", since) + updateChannel := make(chan []model.UpdateItem) + s.updateChannels = append(s.updateChannels, updateChannel) + items := <-updateChannel + + // Remove channel + for i := range s.updateChannels { + if s.updateChannels[i] == updateChannel { + s.updateChannels = append(s.updateChannels[:i], s.updateChannels[i+1:]...) + break + } + } + + return items + } else { + return s.FetchUpdates(since) + } +} + +func (s *Server) MarkConversationAsRead(conversation *model.Conversation) { + conversation.UnreadCount = 0 + + // enqueue update + s.EnqueueUpdateItem(model.UpdateItem{ + Conversation: conversation, + }) +} + +func (s *Server) FetchAttachment(guid string) (io.Reader, error) { + return s.attachmentStore.FetchAttachment(guid) +} + +func (s *Server) UploadAttachment(filename string, reader io.Reader) (*string, error) { + return s.attachmentStore.StoreAttachment(filename, reader) +} + +func (s *Server) DeleteAttachment(guid string) error { + return s.attachmentStore.DeleteAttachment(guid) +} + +// Private + +func (s *Server) registerAuthToken(token *model.AuthToken) { + s.authTokens = append(s.authTokens, *token) +} + +func (s *Server) authenticateToken(token string) bool { + for _, t := range s.authTokens { + if t.SignedToken == token { + return true + } + } + + return false +} diff --git a/mock/web/request_types.go b/mock/web/request_types.go new file mode 100644 index 0000000..e4ba93f --- /dev/null +++ b/mock/web/request_types.go @@ -0,0 +1,12 @@ +package web + +type AuthenticationRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type SendMessageRequest struct { + ConversationGUID string `json:"guid"` + Body string `json:"body"` + TransferGUIDs []string `json:"fileTransferGUIDs"` +} diff --git a/mock/web/response_types.go b/mock/web/response_types.go new file mode 100644 index 0000000..dfab340 --- /dev/null +++ b/mock/web/response_types.go @@ -0,0 +1,5 @@ +package web + +type UploadAttachmentResponse struct { + TransferGUID string `json:"fileTransferGUID"` +} diff --git a/mock/web/server.go b/mock/web/server.go new file mode 100644 index 0000000..5d8fdc6 --- /dev/null +++ b/mock/web/server.go @@ -0,0 +1,476 @@ +package web + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "go.buzzert.net/kordophone-mock/model" + "go.buzzert.net/kordophone-mock/server" + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "github.com/gorilla/websocket" +) + +type MockHTTPServerConfiguration struct { + AuthEnabled bool +} + +type MockHTTPServer struct { + Server server.Server + mux http.ServeMux + authEnabled bool +} + +type AuthError struct { + message string +} + +func (e *AuthError) Error() string { + return e.message +} + +func (m *MockHTTPServer) logRequest(r *http.Request, extras ...string) { + log.Debug().Msgf("%s %s %s", r.Method, r.URL.Path, strings.Join(extras, " ")) +} + +func (m *MockHTTPServer) checkAuthentication(r *http.Request) error { + if !m.authEnabled { + return nil + } + + // Check for Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return &AuthError{"Missing Authorization header"} + } + + // Check for "Bearer" prefix + if authHeader[:7] != "Bearer " { + return &AuthError{"Invalid Authorization header"} + } + + // Check for valid token + token := authHeader[7:] + if !m.Server.CheckBearerToken(token) { + return &AuthError{"Invalid token"} + } + + return nil +} + +func (m *MockHTTPServer) requireAuthentication(w http.ResponseWriter, r *http.Request) bool { + if !m.authEnabled { + return true + } + + if err := m.checkAuthentication(r); err != nil { + log.Error().Err(err).Msg("Error checking authentication") + http.Error(w, err.Error(), http.StatusUnauthorized) + return false + } + + return true +} + +func (m *MockHTTPServer) handleVersion(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "%s", m.Server.Version()) +} + +func (m *MockHTTPServer) handleStatus(w http.ResponseWriter, r *http.Request) { + if !m.requireAuthentication(w, r) { + return + } + + fmt.Fprintf(w, "OK") +} + +func (m *MockHTTPServer) handleConversations(w http.ResponseWriter, r *http.Request) { + if !m.requireAuthentication(w, r) { + return + } + + convos := m.Server.Conversations() + + // Encode convos as JSON + jsonData, err := json.Marshal(convos) + if err != nil { + log.Error().Err(err).Msg("Error marshalling conversations") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Write JSON to response + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) +} + +func (m *MockHTTPServer) handleMessages(w http.ResponseWriter, r *http.Request) { + if !m.requireAuthentication(w, r) { + return + } + + guid := r.URL.Query().Get("guid") + if len(guid) == 0 { + log.Error().Msg("handleMessage: Got empty guid parameter") + http.Error(w, "no guid parameter specified", http.StatusBadRequest) + return + } + + conversation, err := m.Server.ConversationForGUID(guid) + if err != nil { + log.Error().Err(err).Msgf("handleMessage: Error getting conversation (%s)", guid) + http.Error(w, "conversation not found", http.StatusBadRequest) + return + } + + beforeDate := r.URL.Query().Get("beforeDate") + beforeGUID := r.URL.Query().Get("beforeMessageGUID") + afterGUID := r.URL.Query().Get("afterMessageGUID") + limit := r.URL.Query().Get("limit") + + stringOrNil := func(s string) *string { + if len(s) == 0 { + return nil + } + return &s + } + + dateOrNil := func(s string) *model.Date { + if len(s) == 0 { + return nil + } + t, _ := time.Parse(time.RFC3339, s) + date := model.Date(t) + return &date + } + + intOrNil := func(s string) *int { + if len(s) == 0 { + return nil + } + i, _ := strconv.Atoi(s) + return &i + } + + query := server.MessagesQuery{ + Limit: intOrNil(limit), + BeforeDate: dateOrNil(beforeDate), + BeforeGUID: stringOrNil(beforeGUID), + AfterGUID: stringOrNil(afterGUID), + ConversationGUID: conversation.Guid, + } + + messages := m.Server.PerformMessageQuery(&query) + + jsonData, err := json.Marshal(messages) + if err != nil { + log.Error().Err(err).Msg("Error marshalling messages") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Write JSON to response + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) +} + +func (m *MockHTTPServer) handleAuthenticate(w http.ResponseWriter, r *http.Request) { + // Decode request body as AuthenticationRequest + var authReq AuthenticationRequest + err := json.NewDecoder(r.Body).Decode(&authReq) + if err != nil { + log.Error().Err(err).Msg("Authenticate: Error decoding request body") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Authenticate + token, err := m.Server.Authenticate(authReq.Username, authReq.Password) + if err != nil { + log.Error().Err(err).Msg("Authenticate: Error authenticating") + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + // Write response + w.Header().Set("Content-Type", "application/json") + + // Encode token as JSON + jsonData, err := json.Marshal(token) + if err != nil { + log.Error().Err(err).Msg("Error marshalling token") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Write JSON to response + w.Write(jsonData) +} + +func (m *MockHTTPServer) handleNotFound(w http.ResponseWriter, r *http.Request) { + log.Error().Msgf("Unimplemented API endpoint: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) +} + +func (m *MockHTTPServer) handleSendMessage(w http.ResponseWriter, r *http.Request) { + if !m.requireAuthentication(w, r) { + return + } + + // Decode request body as SendMessageRequest + var sendMessageReq SendMessageRequest + err := json.NewDecoder(r.Body).Decode(&sendMessageReq) + if err != nil { + log.Error().Err(err).Msg("SendMessage: Error decoding request body") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Find conversation + conversation, err := m.Server.ConversationForGUID(sendMessageReq.ConversationGUID) + if err != nil { + log.Error().Err(err).Msgf("SendMessage: Error finding conversation (%s)", sendMessageReq.ConversationGUID) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Create Message + message := model.Message{ + Guid: uuid.New().String(), + Text: sendMessageReq.Body, + Date: model.Date(time.Now()), + AttachmentGUIDs: sendMessageReq.TransferGUIDs, + Sender: nil, // me + } + + // Send message + m.Server.SendMessage(conversation, message) + + // Encode response as JSON + jsonData, err := json.Marshal(message) + if err != nil { + log.Error().Err(err).Msg("Error marshalling response") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Write JSON to response + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) +} + +func (m *MockHTTPServer) handlePollUpdates(w http.ResponseWriter, r *http.Request) { + if !m.requireAuthentication(w, r) { + return + } + + // TODO: This should block if we don't have updates for that seq yet. + + seq := -1 + seqString := r.URL.Query().Get("seq") + if len(seqString) > 0 { + var err error + seq, err = strconv.Atoi(seqString) + if err != nil { + log.Error().Err(err).Msg("FetchUpdates: Error parsing seq") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + + // Fetch updates (blocking) + updates := m.Server.FetchUpdatesBlocking(seq) + + if len(updates) == 0 { + // return 205 (Nothing to report) + w.WriteHeader(http.StatusResetContent) + log.Info().Msg("FetchUpdates: Nothing to report") + } else { + // Encode updates as JSON + jsonData, err := json.Marshal(updates) + if err != nil { + log.Error().Err(err).Msg("Error marshalling updates") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Write JSON to response + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) + } +} + +func (m *MockHTTPServer) handleMarkConversation(w http.ResponseWriter, r *http.Request) { + if !m.requireAuthentication(w, r) { + return + } + + guid := r.URL.Query().Get("guid") + if len(guid) == 0 { + log.Error().Msg("handleMarkConversation: Got empty guid parameter") + http.Error(w, "no guid parameter specified", http.StatusBadRequest) + return + } + + // Find conversation + convo, err := m.Server.ConversationForGUID(guid) + if err != nil { + log.Error().Err(err).Msgf("handleMarkConversation: Error finding conversation (%s)", guid) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Mark conversation + m.Server.MarkConversationAsRead(convo) + + // Respond 200 + w.WriteHeader(http.StatusOK) +} + +func (m *MockHTTPServer) handleFetchAttachment(w http.ResponseWriter, r *http.Request) { + if !m.requireAuthentication(w, r) { + return + } + + guid := r.URL.Query().Get("guid") + if guid == "" { + log.Error().Msg("fetchAttachment: Missing 'guid' parameter") + http.Error(w, "no guid parameter specified", http.StatusBadRequest) + return + } + + reader, err := m.Server.FetchAttachment(guid) + if err != nil { + log.Error().Msgf("fetchAttachment: Could not load attachment from store: %s", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + dw := bufio.NewWriter(w) + _, err = dw.ReadFrom(reader) + if err != nil { + log.Error().Msgf("fetchAttachment: Error reading attachment data: %s", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (m *MockHTTPServer) handleUploadAttachment(w http.ResponseWriter, r *http.Request) { + if !m.requireAuthentication(w, r) { + return + } + + filename := r.URL.Query().Get("filename") + if filename == "" { + log.Error().Msgf("uploadAttachment: filename not provided") + http.Error(w, "filename not provided", http.StatusBadRequest) + return + } + + guid, err := m.Server.UploadAttachment(filename, r.Body) + if err != nil { + log.Error().Msgf("uploadAttachment: error storing attachment: %s", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + response := UploadAttachmentResponse{ + TransferGUID: *guid, + } + + jsonData, err := json.Marshal(response) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) +} + +func (m *MockHTTPServer) handleUpdates(w http.ResponseWriter, r *http.Request) { + if !m.requireAuthentication(w, r) { + return + } + + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, + } + + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Error().Err(err).Msg("websocket upgrade failed") + return + } + m.handleUpdatesWebsocket(c) +} + +func (m *MockHTTPServer) handleUpdatesWebsocket(c *websocket.Conn) { + // Fetch updates continuously + defer c.Close() + + + // Start a goroutine to handle incoming messages (pings, usually) + go func() { + for { + _, _, err := c.ReadMessage() + if err != nil { + log.Error().Err(err).Msg("WebSocket read error") + return + } + } + }() + + for { + // Fetch updates (blocking) + updates := m.Server.FetchUpdatesBlocking(-1) + + // Send updates to client + err := c.WriteJSON(updates) + if err != nil { + log.Error().Err(err).Msg("handleUpdatesWebsocket: Error sending updates to client (probably disconnected)") + return + } + } +} + +func (m *MockHTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + m.logRequest(r, r.URL.Query().Encode()) + m.mux.ServeHTTP(w, r) +} + +func NewMockHTTPServer(config MockHTTPServerConfiguration) *MockHTTPServer { + this := MockHTTPServer{ + Server: *server.NewServer(), + mux: *http.NewServeMux(), + authEnabled: config.AuthEnabled, + } + + // Redirect /api/* to /* + this.mux.Handle("/api/", http.StripPrefix("/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, r.URL.Path, http.StatusMovedPermanently) + }))) + + this.mux.Handle("/version", http.HandlerFunc(this.handleVersion)) + this.mux.Handle("/conversations", http.HandlerFunc(this.handleConversations)) + this.mux.Handle("/status", http.HandlerFunc(this.handleStatus)) + this.mux.Handle("/authenticate", http.HandlerFunc(this.handleAuthenticate)) + this.mux.Handle("/messages", http.HandlerFunc(this.handleMessages)) + this.mux.Handle("/pollUpdates", http.HandlerFunc(this.handlePollUpdates)) + this.mux.Handle("/sendMessage", http.HandlerFunc(this.handleSendMessage)) + this.mux.Handle("/markConversation", http.HandlerFunc(this.handleMarkConversation)) + this.mux.Handle("/attachment", http.HandlerFunc(this.handleFetchAttachment)) + this.mux.Handle("/uploadAttachment", http.HandlerFunc(this.handleUploadAttachment)) + this.mux.Handle("/updates", http.HandlerFunc(this.handleUpdates)) + + this.mux.Handle("/", http.HandlerFunc(this.handleNotFound)) + + return &this +} diff --git a/mock/web/server_test.go b/mock/web/server_test.go new file mode 100644 index 0000000..8261116 --- /dev/null +++ b/mock/web/server_test.go @@ -0,0 +1,731 @@ +package web_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "go.buzzert.net/kordophone-mock/data" + "go.buzzert.net/kordophone-mock/model" + "go.buzzert.net/kordophone-mock/server" + "go.buzzert.net/kordophone-mock/web" + "github.com/gorilla/websocket" +) + +func TestVersion(t *testing.T) { + s := httptest.NewServer(web.NewMockHTTPServer(web.MockHTTPServerConfiguration{})) + + resp, err := http.Get(s.URL + "/version") + if err != nil { + t.Fatalf("TestVersion error: %s", err) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + if string(body) != server.VERSION { + t.Fatalf("Unexpected return value: %s (expected %s)", body, "1.0") + } +} + +func TestStatus(t *testing.T) { + s := httptest.NewServer(web.NewMockHTTPServer(web.MockHTTPServerConfiguration{})) + + resp, err := http.Get(s.URL + "/status") + if err != nil { + t.Fatalf("TestStatus error: %s", err) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + if string(body) != "OK" { + t.Fatalf("Unexpected return value: %s (expected %s)", body, "OK") + } +} + +func TestConversations(t *testing.T) { + server := web.NewMockHTTPServer(web.MockHTTPServerConfiguration{}) + httpServer := httptest.NewServer(server) + + conversation := model.Conversation{ + Date: model.Date(time.Now()), + Participants: []string{"Alice", "Bob"}, + UnreadCount: 1, + LastMessagePreview: "Hello world", + Guid: "1234567890", + } + + server.Server.AddConversation(conversation) + + resp, err := http.Get(httpServer.URL + "/conversations") + if err != nil { + t.Fatalf("TestConversations error: %s", err) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + var convos []model.Conversation + err = json.Unmarshal(body, &convos) + if err != nil { + t.Fatalf("Error unmarshalling JSON: %s", err) + } + + if len(convos) != 1 { + t.Fatalf("Unexpected number of conversations: %d (expected %d)", len(convos), 1) + } + + testConversation := &convos[0] + if testConversation.Equal(&conversation) != true { + t.Fatalf("Unexpected conversation: %v (expected %v)", convos[0], conversation) + } +} + +func TestMessages(t *testing.T) { + server := web.NewMockHTTPServer(web.MockHTTPServerConfiguration{}) + httpServer := httptest.NewServer(server) + + const sender = "Alice" + const text = "This is a test." + + conversation := model.Conversation{ + Date: model.Date(time.Now()), + Participants: []string{sender}, + UnreadCount: 1, + Guid: "1234567890", + } + + server.Server.AddConversation(conversation) + + message := model.Message{ + Text: text, + Sender: &conversation.Participants[0], + Date: model.Date(time.Now()), + } + + server.Server.AppendMessageToConversation(&conversation, message) + + resp, err := http.Get(httpServer.URL + "/messages?guid=" + conversation.Guid) + if err != nil { + t.Fatalf("TestMessages error: %s", err) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + var messages []model.Message + err = json.Unmarshal(body, &messages) + if err != nil { + t.Fatalf("Error unmarshalling JSON: %s", err) + } + + if len(messages) != 1 { + t.Fatalf("Unexpected num messages: %d (expected %d)", len(messages), 1) + } + + fetchedMessage := messages[0] + if fetchedMessage.Text != message.Text { + t.Fatalf("Unexpected message text: %s (expected %s)", fetchedMessage.Text, message.Text) + } + + if *fetchedMessage.Sender != *message.Sender { + t.Fatalf("Unexpected message sender: %s (expected %s)", *fetchedMessage.Sender, *message.Sender) + } +} + +func TestAuthentication(t *testing.T) { + s := web.NewMockHTTPServer(web.MockHTTPServerConfiguration{AuthEnabled: true}) + httpServer := httptest.NewServer(s) + + // First, try authenticated request and make sure it fails + resp, err := http.Get(httpServer.URL + "/status") + if err != nil { + t.Fatalf("TestAuthentication status error: %s", err) + } + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("Unexpected status code: %d (expected %d)", resp.StatusCode, http.StatusUnauthorized) + } + + tryAuthenticate := func(username string, password string) *http.Response { + authRequest := web.AuthenticationRequest{ + Username: username, + Password: password, + } + + authRequestJSON, err := json.Marshal(authRequest) + if err != nil { + t.Fatalf("Error marshalling JSON: %s", err) + } + + resp, err := http.Post(httpServer.URL+"/authenticate", "application/json", io.NopCloser(bytes.NewReader(authRequestJSON))) + if err != nil { + t.Fatalf("TestAuthentication error: %s", err) + } + + return resp + } + + // Send authentication request with bad credentials + resp = tryAuthenticate("bad", "credentials") + if resp.StatusCode == http.StatusOK { + t.Fatalf("Unexpected status code: %d (expected %d)", resp.StatusCode, http.StatusUnauthorized) + } + + // Now try good credentials + resp = tryAuthenticate(server.AUTH_USERNAME, server.AUTH_PASSWORD) + if resp.StatusCode != http.StatusOK { + t.Fatalf("Unexpected status code: %d (expected %d)", resp.StatusCode, http.StatusOK) + } + + // Decode the token from the body. + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + var authToken model.AuthToken + err = json.Unmarshal(body, &authToken) + if err != nil { + t.Fatalf("Error unmarshalling JSON: %s, body: %s", err, body) + } + + if authToken.SignedToken == "" { + t.Fatalf("Unexpected empty signed token") + } + + // Send a request with the signed token + req, err := http.NewRequest(http.MethodGet, httpServer.URL+"/status", nil) + if err != nil { + t.Fatalf("Error creating request: %s", err) + } + + req.Header.Set("Authorization", "Bearer "+authToken.SignedToken) + + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Error sending request: %s", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Unexpected status code: %d (expected %d)", resp.StatusCode, http.StatusUnauthorized) + } + + body, err = io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + if string(body) != "OK" { + t.Fatalf("Unexpected body: %s (expected %s)", body, "OK") + } +} + +func TestUpdates(t *testing.T) { + s := web.NewMockHTTPServer(web.MockHTTPServerConfiguration{AuthEnabled: false}) + httpServer := httptest.NewServer(s) + + messageSeq := 0 + + // Mock conversation + guid := "1234567890" + conversation := model.Conversation{ + Date: model.Date(time.Now()), + Participants: []string{"Alice"}, + UnreadCount: 0, + Guid: guid, + } + s.Server.AddConversation(conversation) + + // Receive a message + message := model.Message{ + Text: "This is a test.", + Sender: &conversation.Participants[0], + Date: model.Date(time.Now()), + } + + // This should enqueue an update item + s.Server.ReceiveMessage(&conversation, message) + + resp, err := http.Get(httpServer.URL + fmt.Sprintf("/pollUpdates?seq=%d", messageSeq)) + if err != nil { + t.Fatalf("TestUpdates error: %s", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Unexpected status code: %d (expected %d)", resp.StatusCode, http.StatusOK) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + var updates []model.UpdateItem + err = json.Unmarshal(body, &updates) + if err != nil { + t.Fatalf("Error unmarshalling JSON: %s", err) + } + + if len(updates) != 1 { + t.Fatalf("Unexpected num updates: %d (expected %d)", len(updates), 1) + } + + update := updates[0] + + // Message seq should be >= messageSeq + messageSeq = update.MessageSequenceNumber + if messageSeq != 1 { + t.Fatalf("Unexpected message seq: %d (expected >= 0)", messageSeq) + } + + if update.Conversation.Guid != conversation.Guid { + t.Fatalf("Unexpected conversation guid: %s (expected %s)", update.Conversation.Guid, conversation.Guid) + } + + if update.Message.Text != message.Text { + t.Fatalf("Unexpected message text: %s (expected %s)", update.Message.Text, message.Text) + } +} + +type MessageUpdateError struct { + Message string +} + +func (e MessageUpdateError) Error() string { + return e.Message +} + +func TestUpdatesWebsocket(t *testing.T) { + s := web.NewMockHTTPServer(web.MockHTTPServerConfiguration{AuthEnabled: false}) + httpServer := httptest.NewServer(s) + + // Mock conversation + guid := "1234567890" + conversation := model.Conversation{ + Date: model.Date(time.Now()), + Participants: []string{"Alice"}, + UnreadCount: 0, + Guid: guid, + } + s.Server.AddConversation(conversation) + + // Receive a message + message := model.Message{ + Text: "This is a test.", + Sender: &conversation.Participants[0], + Date: model.Date(time.Now()), + } + + // Open websocket connection + wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/updates" + ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Error opening websocket: %s", err) + } + + // Await messages on the websocket + messageReceived := make(chan bool) + errorEncountered := make(chan error) + go func() { + // Read from websocket + var updates []model.UpdateItem + err := ws.ReadJSON(&updates) + + if err != nil { + errorEncountered <- err + return + } else { + if len(updates) != 1 { + errorEncountered <- MessageUpdateError{ + fmt.Sprintf("Unexpected num updates: %d (expected %d)", len(updates), 1), + } + return + } + + update := updates[0] + if update.Conversation.Guid != conversation.Guid { + errorEncountered <- MessageUpdateError{ + fmt.Sprintf("Unexpected conversation guid: %s (expected %s)", update.Conversation.Guid, conversation.Guid), + } + return + } + + if update.Message.Text != message.Text { + errorEncountered <- MessageUpdateError{ + fmt.Sprintf("Unexpected message text: %s (expected %s)", update.Message.Text, message.Text), + } + return + } + + messageReceived <- true + } + + }() + + // sleep for a bit to allow the websocket to connect + time.Sleep(100 * time.Millisecond) + + // This should enqueue an update item + s.Server.ReceiveMessage(&conversation, message) + + // Await expectation + select { + case <-messageReceived: + // COOL + case err := <-errorEncountered: + t.Fatalf("Error encountered reading from websocket: %s", err) + case <-time.After(1 * time.Second): + t.Fatalf("Timed out waiting for websocket message") + } +} + +func TestMarkConversation(t *testing.T) { + s := web.NewMockHTTPServer(web.MockHTTPServerConfiguration{AuthEnabled: false}) + httpServer := httptest.NewServer(s) + + // Mock conversation + guid := "1234567890" + conversation := model.Conversation{ + Date: model.Date(time.Now()), + Participants: []string{"Alice"}, + UnreadCount: 0, + Guid: guid, + } + + s.Server.AddConversation(conversation) + + // Receive message to mark as unread + message := model.Message{ + Text: "This is a test.", + Sender: &conversation.Participants[0], + Date: model.Date(time.Now()), + } + + s.Server.ReceiveMessage(&conversation, message) + + if convo, _ := s.Server.ConversationForGUID(guid); convo.UnreadCount != 1 { + t.Fatalf("Unexpected unread count: %d (expected %d)", convo.UnreadCount, 1) + } + + // Mark conversation as read + resp, err := http.Post(httpServer.URL+"/markConversation?guid="+guid, "", nil) + if err != nil { + t.Fatalf("TestMarkConversation error: %s", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Unexpected status code: %d (expected %d)", resp.StatusCode, http.StatusOK) + } + + if convo, _ := s.Server.ConversationForGUID(guid); convo.UnreadCount != 0 { + t.Fatalf("Unexpected unread count: %d (expected %d)", convo.UnreadCount, 0) + } +} + +func TestMessageQueries(t *testing.T) { + s := web.NewMockHTTPServer(web.MockHTTPServerConfiguration{AuthEnabled: false}) + httpServer := httptest.NewServer(s) + + // Mock conversation + guid := "1234567890" + conversation := model.Conversation{ + Date: model.Date(time.Now()), + Participants: []string{"Alice"}, + UnreadCount: 0, + Guid: guid, + } + + s.Server.AddConversation(conversation) + + // Mock messages + numMessages := 20 + for i := 0; i < numMessages; i++ { + message := data.GenerateRandomMessage(conversation.Participants) + s.Server.AppendMessageToConversation(&conversation, message) + } + + // Pick a pivot message from the sorted list + sortedMessages := s.Server.MessagesForConversation(&conversation) + pivotMessage := sortedMessages[len(sortedMessages)/2] + + // Query messages before the pivot, test limit also + limitMessageCount := 5 + resp, err := http.Get(httpServer.URL + fmt.Sprintf("/messages?guid=%s&beforeMessageGUID=%s&limit=%d", guid, pivotMessage.Guid, limitMessageCount)) + if err != nil { + t.Fatalf("TestMessageQueries error: %s", err) + } + + // Decode response + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + var messages []model.Message + err = json.Unmarshal(body, &messages) + if err != nil { + t.Fatalf("Error unmarshalling JSON: %s", err) + } + + if len(messages) != limitMessageCount { + t.Fatalf("Unexpected num messages: %d (expected %d)", len(messages), limitMessageCount) + } + + // Make sure before query is exclusive of the pivot message + for _, message := range messages { + if message.Guid == pivotMessage.Guid { + t.Fatalf("Found pivot guid in before query: %s (expected != %s)", message.Guid, pivotMessage.Guid) + } + } + + // Make sure messages are actually before the pivot + for _, message := range messages { + if message.Date.After(pivotMessage.Date) { + t.Fatalf("Unexpected message date.") + } + } + + // Query messages after the pivot + resp, err = http.Get(httpServer.URL + fmt.Sprintf("/messages?guid=%s&afterMessageGUID=%s", guid, pivotMessage.Guid)) + if err != nil { + t.Fatalf("TestMessageQueries error: %s", err) + } + + // Decode response + body, err = io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + messages = []model.Message{} + err = json.Unmarshal(body, &messages) + if err != nil { + t.Fatalf("Error unmarshalling JSON: %s", err) + } + + // Make sure after query is exclusive of the pivot message + for _, message := range messages { + if message.Guid == pivotMessage.Guid { + t.Fatalf("Found pivot guid in after query: %s (expected != %s)", message.Guid, pivotMessage.Guid) + } + } + + // Make sure messages are actually after the pivot + for _, message := range messages { + if message.Date.Before(pivotMessage.Date) { + t.Fatalf("Unexpected message date") + } + } + +} + +func trySendMessage(t *testing.T, httpServer *httptest.Server, request web.SendMessageRequest) string { + // Encode as json + requestJSON, err := json.Marshal(request) + if err != nil { + t.Fatalf("Error marshalling JSON: %s", err) + } + + // Send request + resp, err := http.Post(httpServer.URL+"/sendMessage", "application/json", io.NopCloser(bytes.NewReader(requestJSON))) + if err != nil { + t.Fatalf("TestSendMessage error: %s", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Unexpected status code: %d (expected %d)", resp.StatusCode, http.StatusOK) + } + + // Decode response + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + var response model.Message + err = json.Unmarshal(body, &response) + if err != nil { + t.Fatalf("Error unmarshalling JSON: %s", err) + } + + if response.Guid == "" { + t.Fatalf("Unexpected empty guid") + } + + return response.Guid +} + +func TestSendMessage(t *testing.T) { + s := web.NewMockHTTPServer(web.MockHTTPServerConfiguration{AuthEnabled: false}) + httpServer := httptest.NewServer(s) + + // Mock conversation + guid := "1234567890" + conversation := model.Conversation{ + Date: model.Date(time.Now()), + Participants: []string{"Alice"}, + UnreadCount: 0, + Guid: guid, + } + + s.Server.AddConversation(conversation) + + // Send it + request := web.SendMessageRequest{ + ConversationGUID: guid, + Body: "hello there", + TransferGUIDs: []string{}, + } + + responseGuid := trySendMessage(t, httpServer, request) + + // Make sure message is present + messages := s.Server.MessagesForConversation(&conversation) + found := false + for _, message := range messages { + if message.Guid == responseGuid { + found = true + break + } + } + + if found != true { + t.Fatalf("Message not found in conversation") + } +} + +func tryUploadAttachment(t *testing.T, testData string, httpServer *httptest.Server) string { + // Send upload request + attachmentDataReader := strings.NewReader(testData) + resp, err := http.Post(httpServer.URL+"/uploadAttachment?filename=test.txt", "application/data", attachmentDataReader) + if err != nil { + t.Fatalf("Error uploading attachment: %s", err) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + var response web.UploadAttachmentResponse + err = json.Unmarshal(body, &response) + if err != nil { + t.Fatalf("Error decoding response: %s", err) + } + + return response.TransferGUID +} + +func tryFetchAttachment(t *testing.T, httpServer *httptest.Server, guid string) []byte { + resp, err := http.Get(httpServer.URL + fmt.Sprintf("/attachment?guid=%s", guid)) + if err != nil { + t.Fatalf("Error fetching attachment: %s", err) + } + + responseData, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error reading attachment data: %s", err) + } + + return responseData +} + +func TestAttachments(t *testing.T) { + s := web.NewMockHTTPServer(web.MockHTTPServerConfiguration{AuthEnabled: false}) + httpServer := httptest.NewServer(s) + + testData := "hello world!" + guid := tryUploadAttachment(t, testData, httpServer) + + // Cleanup after ourselves + defer s.Server.DeleteAttachment(guid) + + // Fetch it back + responseData := tryFetchAttachment(t, httpServer, guid) + + if string(responseData) != testData { + t.Fatalf("Didn't get expected response data: %s (got %s)", testData, responseData) + } +} + +func TestSendMessageWithAttachment(t *testing.T) { + s := web.NewMockHTTPServer(web.MockHTTPServerConfiguration{AuthEnabled: false}) + httpServer := httptest.NewServer(s) + + // Mock conversation + conversation := model.Conversation{ + Date: model.Date(time.Now()), + Participants: []string{"Alice"}, + UnreadCount: 0, + Guid: "123456789", + } + + s.Server.AddConversation(conversation) + + testData := "attachment data" + attachmentGuid := tryUploadAttachment(t, testData, httpServer) + + messageRequest := web.SendMessageRequest{ + ConversationGUID: conversation.Guid, + Body: "", + TransferGUIDs: []string{attachmentGuid}, + } + + sentGuid := trySendMessage(t, httpServer, messageRequest) + + // See if our message has that attachment + resp, err := http.Get(httpServer.URL + "/messages?guid=" + conversation.Guid) + if err != nil { + t.Fatalf("TestMessages error: %s", err) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + var messages []model.Message + err = json.Unmarshal(body, &messages) + if err != nil { + t.Fatalf("Error unmarshalling JSON: %s", err) + } + + if len(messages) != 1 { + t.Fatalf("Unexpected num messages: %d (expected %d)", len(messages), 1) + } + + onlyMessage := messages[0] + if onlyMessage.Guid != sentGuid { + t.Fatalf("Unexpected guid: %s (expected %s)", onlyMessage.Guid, sentGuid) + } + + if len(onlyMessage.AttachmentGUIDs) != 1 { + t.Fatalf("Message returned didn't have expected attachment guids") + } + + if onlyMessage.AttachmentGUIDs[0] != attachmentGuid { + t.Fatalf("Message returned had wrong attachment guid: %s (expected %s)", onlyMessage.AttachmentGUIDs[0], attachmentGuid) + } + + // See if we get data back + fetchedData := tryFetchAttachment(t, httpServer, attachmentGuid) + if string(fetchedData) != testData { + t.Fatalf("Sent message attachment had incorrect data: %s (expected %s)", fetchedData, testData) + } +}