Private
Public Access
1
0

Add 'mock/' from commit '2041d3ce6377da091eca17cf9d8ad176a3024616'

git-subtree-dir: mock
git-subtree-mainline: 8216d7c706
git-subtree-split: 2041d3ce63
This commit is contained in:
2025-09-06 19:35:49 -07:00
20 changed files with 2661 additions and 0 deletions

2
mock/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
kordophone-mock

4
mock/Makefile Normal file
View File

@@ -0,0 +1,4 @@
.PHONY: kordophone-mock
kordophone-mock:
go build

380
mock/data/generators.go Normal file
View File

@@ -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},
}
}

18
mock/go.mod Normal file
View File

@@ -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
)

66
mock/go.sum Normal file
View File

@@ -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=

83
mock/main.go Normal file
View File

@@ -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(`
_ __ _ _
| |/ /___ _ _ __| |___ _ __| |_ ___ _ _ ___
| ' </ _ \ '_/ _' / _ \ '_ \ ' \/ _ \ ' \/ -_)
|_|\_\___/_| \__,_\___/ .__/_||_\___/_||_\___|
|_|
`)
}
func main() {
debugLogging := flag.Bool("debug", false, "enable debug logging")
authEnabled := flag.Bool("auth", false, "enable authentication")
flag.Parse()
setupLogging(*debugLogging)
printWelcomeMessage()
c := web.MockHTTPServerConfiguration{
AuthEnabled: *authEnabled,
}
addr := ":5738"
s := web.NewMockHTTPServer(c)
httpServer := &http.Server{
Addr: addr,
Handler: s,
}
// Populate with test data
s.Server.PopulateWithTestData()
log.Info().Msgf("Generated test data. %d conversations", len(s.Server.Conversations()))
log.Info().Msgf("Listening on %s", addr)
go httpServer.ListenAndServe()
rl := prompt.NewPrompt(&s.Server)
// Hook logging so we can refresh the prompt when something is logged.
log.Logger = log.Logger.Hook(&LoggingHook{prompt: rl})
// Read indefinitely
err := rl.StartInteractive()
if err != nil {
log.Error().Err(err)
}
}

57
mock/model/attachment.go Normal file
View File

@@ -0,0 +1,57 @@
package model
import (
"bufio"
"io"
"os"
"path"
"github.com/google/uuid"
)
type AttachmentStore struct {
basePath string
}
func NewAttachmentStore(basePath string) AttachmentStore {
_, err := os.Stat(basePath)
if os.IsNotExist(err) {
os.MkdirAll(basePath, 0755)
}
return AttachmentStore{
basePath: basePath,
}
}
func (s *AttachmentStore) FetchAttachment(guid string) (io.Reader, error) {
fullPath := path.Join(s.basePath, guid)
f, err := os.Open(fullPath)
if err != nil {
return nil, err
}
return bufio.NewReader(f), nil
}
func (s *AttachmentStore) StoreAttachment(filename string, reader io.Reader) (*string, error) {
// Generate GUID
guid := uuid.New().String()
fullPath := path.Join(s.basePath, guid)
f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY, 0755)
if err != nil {
return nil, err
}
r := bufio.NewReader(reader)
w := bufio.NewWriter(f)
_, err = w.ReadFrom(r)
return &guid, err
}
func (s *AttachmentStore) DeleteAttachment(guid string) error {
fullPath := path.Join(s.basePath, guid)
return os.Remove(fullPath)
}

59
mock/model/authtoken.go Normal file
View File

@@ -0,0 +1,59 @@
package model
import (
"encoding/base64"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/rs/zerolog/log"
)
type AuthToken struct {
SignedToken string `json:"jwt"`
token jwt.Token
}
type TokenGenerationError struct {
message string
}
func (e *TokenGenerationError) Error() string {
return e.message
}
// Create a struct to hold your custom claims
type customClaims struct {
Username string `json:"username"`
jwt.StandardClaims
}
const signingKey = "nDjYmTjoPrAGzuyhHz6Dq5bqcRrEZJc5Ls3SQcdylBI="
func NewAuthToken(username string) (*AuthToken, error) {
claims := customClaims{
Username: username,
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(time.Hour * 24 * 5).Unix(), // 5 days
},
}
// Create a new JWT token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
if token == nil {
log.Error().Msg("Error creating Jwt Token")
return nil, &TokenGenerationError{"Error creating Jwt Token"}
}
// Sign the token with the specified signing key
decodedSigningKey, _ := base64.StdEncoding.DecodeString(signingKey)
signedToken, err := token.SignedString(decodedSigningKey)
if err != nil {
log.Error().Err(err).Msg("Error signing Jwt Token")
return nil, &TokenGenerationError{"Error signing Jwt Token"}
}
return &AuthToken{
SignedToken: signedToken,
token: *token,
}, nil
}

View File

@@ -0,0 +1,73 @@
package model
import (
"strings"
"time"
"github.com/rs/zerolog"
)
type Conversation struct {
Date Date `json:"date"`
Participants []string `json:"participantDisplayNames"`
DisplayName *string `json:"displayName,omitempty"` // Optional
UnreadCount int `json:"unreadCount"`
LastMessagePreview string `json:"lastMessagePreview"`
LastMessage Message `json:"lastMessage"`
Guid string `json:"guid"`
}
func (c *Conversation) GetDisplayName() string {
if c.DisplayName == nil {
return strings.Join(c.Participants, ",")
}
return *c.DisplayName
}
func (c *Conversation) MarshalZerologObject(e *zerolog.Event) {
e.Str("guid", c.Guid)
e.Time("date", time.Time(c.Date))
e.Int("unreadCount", c.UnreadCount)
e.Str("lastMessagePreview", c.LastMessagePreview)
e.Strs("participants", c.Participants)
if c.DisplayName != nil {
e.Str("displayName", *c.DisplayName)
}
}
func (c *Conversation) Equal(o *Conversation) bool {
if c.Guid != o.Guid {
return false
}
if !c.Date.Equal(o.Date) {
return false
}
if c.UnreadCount != o.UnreadCount {
return false
}
if c.LastMessagePreview != o.LastMessagePreview {
return false
}
if len(c.Participants) != len(o.Participants) {
return false
}
for i, p := range c.Participants {
if p != o.Participants[i] {
return false
}
}
if c.DisplayName != nil && o.DisplayName != nil {
if *c.DisplayName != *o.DisplayName {
return false
}
}
return true
}

53
mock/model/date.go Normal file
View File

@@ -0,0 +1,53 @@
package model
import (
"errors"
"fmt"
"time"
)
type Date time.Time
func (d Date) Equal(other Date) bool {
// usec and nsec are lost in ISO8601 conversion
dr := time.Time(d).Round(time.Minute)
or := time.Time(other).Round(time.Minute)
return dr.Equal(or)
}
func (d Date) After(other Date) bool {
return time.Time(d).After(time.Time(other))
}
func (d Date) Before(other Date) bool {
return time.Time(d).Before(time.Time(other))
}
func (d Date) Format(layout string) string {
return time.Time(d).Format(layout)
}
func (d Date) MarshalJSON() ([]byte, error) {
// Must use ISO8601
formatted := fmt.Sprintf("\"%s\"", time.Time(d).Format("2006-01-02T15:04:05+00:00"))
return []byte(formatted), nil
}
func (d *Date) UnmarshalJSON(data []byte) error {
if len(data) < 2 || data[0] != '"' || data[len(data)-1] != '"' {
return errors.New("Time.UnmarshalJSON: input is not a JSON string")
}
data = data[len(`"`) : len(data)-len(`"`)]
var err error
var t time.Time
t, err = time.ParseInLocation("2006-01-02T15:04:05+00:00", string(data), time.Now().Location())
if err != nil {
return err
}
*d = Date(t)
return nil
}

35
mock/model/message.go Normal file
View File

@@ -0,0 +1,35 @@
package model
import (
"time"
"github.com/rs/zerolog"
)
type Message struct {
Text string `json:"text"`
Guid string `json:"guid"`
Sender *string `json:"sender,omitempty"` // Optional: nil means from "me"
Date Date `json:"date"`
// Map of attachment GUID to attachment metadata
AttachmentGUIDs []string `json:"fileTransferGUIDs,omitempty"`
AttachmentMetadata *map[string]AttributionInfo `json:"attachmentMetadata,omitempty"` // Optional
}
type AttributionInfo struct {
ThumbnailWidth int `json:"pgensw"`
ThumbnailHeight int `json:"pgensh"`
}
func (c Message) MarshalZerologObject(e *zerolog.Event) {
e.Str("guid", c.Guid)
e.Str("text", c.Text)
e.Time("date", time.Time(c.Date))
if c.Sender != nil {
e.Str("sender", *c.Sender)
} else {
e.Str("sender", "(Me)")
}
}

26
mock/model/update.go Normal file
View File

@@ -0,0 +1,26 @@
package model
import "github.com/rs/zerolog"
type UpdateItem struct {
MessageSequenceNumber int `json:"messageSequenceNumber"`
Conversation *Conversation `json:"conversation,omitempty"`
Message *Message `json:"message,omitempty"`
}
func New(conversation *Conversation, message *Message) *UpdateItem {
return &UpdateItem{
Conversation: conversation,
Message: message,
}
}
func (i *UpdateItem) MarshalZerologObject(e *zerolog.Event) {
e.Int("messageSequenceNumber", i.MessageSequenceNumber)
if i.Conversation != nil {
e.Object("conversation", i.Conversation)
}
if i.Message != nil {
e.Object("message", i.Message)
}
}

218
mock/prompt/prompt.go Normal file
View File

@@ -0,0 +1,218 @@
package prompt
import (
"fmt"
"io"
"strings"
"time"
"github.com/chzyer/readline"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"go.buzzert.net/kordophone-mock/model"
"go.buzzert.net/kordophone-mock/server"
)
type Prompt struct {
rl *readline.Instance
server *server.Server
}
func (p *Prompt) conversationForGUID(guid string) (*model.Conversation, error) {
if guid == "*" {
// This means any conversation: return the first one (that's not the special Attachments convo)
convo := p.server.SortedConversations()[0]
if convo.DisplayName != nil && *convo.DisplayName == server.ATTACHMENT_CONVO_DISP_NAME {
return &p.server.SortedConversations()[1], nil
}
return &convo, nil
}
return p.server.ConversationForGUID(guid)
}
func (p *Prompt) listConversations() {
conversations := p.server.SortedConversations()
for _, c := range conversations {
fmt.Printf("%s %s \t %s ", c.Guid, c.GetDisplayName(), c.Date.Format("2006-01-02 15:04:05"))
if c.UnreadCount > 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] <guid>")
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 <guid> <msg>")
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: <command> [args]")
fmt.Println("Where <guid> is specified, '*' can be provided as a wildcard.")
fmt.Println()
fmt.Println("Commands:")
fmt.Println("\tls list conversations")
fmt.Println("\tls <guid> list messages for conversation")
fmt.Println("\tmark [-r] <guid> mark conversation as unread/[r]ead")
fmt.Println("\trecv <guid> <msg> 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()
}

View File

@@ -0,0 +1,6 @@
package resources
import _ "embed"
//go:embed sedona.jpg
var TestAttachmentData []byte

BIN
mock/resources/sedona.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

357
mock/server/server.go Normal file
View File

@@ -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
}

12
mock/web/request_types.go Normal file
View File

@@ -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"`
}

View File

@@ -0,0 +1,5 @@
package web
type UploadAttachmentResponse struct {
TransferGUID string `json:"fileTransferGUID"`
}

476
mock/web/server.go Normal file
View File

@@ -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
}

731
mock/web/server_test.go Normal file
View File

@@ -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)
}
}