diff --git a/bridge.go b/bridge.go index 51c2229..d2896aa 100644 --- a/bridge.go +++ b/bridge.go @@ -25,7 +25,8 @@ CREATE TABLE IF NOT EXISTS persons ( phone TEXT PRIMARY KEY, center_id INTEGER NOT NULL, group_num INTEGER NOT NULL, - status INTEGER NOT NULL + status INTEGER NOT NULL, + age INTEGER NOT NULL ); ` var schemaCalls = ` @@ -36,7 +37,8 @@ CREATE TABLE IF NOT EXISTS calls ( capacity INTEGER NOT NULL, time_start DATETIME NOT NULL, time_end DATETIME NOT NULL, - young_only INTEGER NOT NULL, + age_min INTEGER NOT NULL, + age_max INTEGER NOT NULL, loc_name TEXT NOT NULL, loc_street TEXT NOT NULL, loc_housenr TEXT NOT NULL, @@ -68,11 +70,12 @@ CREATE TABLE IF NOT EXISTS invitations ( func NewBridge() *Bridge { log.Info("Creating new bridge") - log.Info("Using database:", dbPath) + + log.Info("Using database:", os.Getenv("IMPF_DB_FILE")) // Open connection to database file. Will be created if it does not already // exist. Exit application on errors, we can't continue without database - db, err := sqlx.Connect("sqlite3", dbPath) + db, err := sqlx.Connect("sqlite3", os.Getenv("IMPF_DB_FILE")) // Only required because of a bug with sqlx and sqlite. // TODO remove when migrating to postgresql if performance is too bad @@ -236,7 +239,8 @@ func (b *Bridge) AddCall(call Call) error { capacity, time_start, time_end, - young_only, + age_min, + age_max, loc_name, loc_street, loc_housenr, @@ -249,7 +253,8 @@ func (b *Bridge) AddCall(call Call) error { :capacity, :time_start, :time_end, - :young_only, + :age_min, + :age_max, :loc_name, :loc_street, :loc_housenr, @@ -278,8 +283,8 @@ func (b *Bridge) AddPerson(person Person) error { tx := b.db.MustBegin() if res, err = tx.NamedExec( - "INSERT INTO persons (center_id, group_num, phone, status) VALUES "+ - "(:center_id, :group_num, :phone, :status)", &person); err != nil { + "INSERT INTO persons (center_id, group_num, phone, status, age) VALUES "+ + "(:center_id, :group_num, :phone, :status, :age)", &person); err != nil { return err } @@ -302,8 +307,8 @@ func (b *Bridge) AddPersons(persons []Person) error { tx := b.db.MustBegin() for k := range persons { if _, err := tx.NamedExec( - "INSERT INTO persons (center_id, group_num, phone, status) VALUES "+ - "(:center_id, :group_num, :phone, :status)", &persons[k]); err != nil { + "INSERT INTO persons (center_id, group_num, phone, status, age) VALUES "+ + "(:center_id, :group_num, :phone, :status, :age)", &persons[k]); err != nil { return err } @@ -358,6 +363,23 @@ func (b *Bridge) GetActiveCalls() ([]Call, error) { return calls, err } +// GetAllCalls returns a list of all calls +func (b *Bridge) GetAllCalls() ([]Call, error) { + + // Query the database, storing results in a []User (wrapped in []interface{}) + calls := []Call{} + // b.db.Select(&calls, "SELECT * FROM calls ORDER BY time_start ASC")j + err := b.db.Select(&calls, "SELECT * FROM calls") + + if err != nil { + log.Error(err) + return calls, err + } + + log.Debugf("Found calls: %+v\n", calls) + return calls, err +} + // GetNextPersonsForCall finds the next `num` persons that should be notified // for a callID. Selection is based on group_num //TODO FIXME @@ -370,19 +392,26 @@ func (b *Bridge) GetNextPersonsForCall(num, callID int) ([]Person, error) { // Get all groups log.Debugf("Retrieving next persons %v for call ID: %v\n", num, callID) + var err error + var call CallStatus + + call, err = bridge.GetCallStatus(strconv.Itoa(callID)) + if err != nil { + return []Person{}, err + } persons := []Person{} - err := b.db.Select(&persons, + err = b.db.Select(&persons, `SELECT * FROM persons - WHERE id NOT IN ( - SELECT id FROM invitations - WHERE status NOT IN ( - "accepted", "notified" - ) - OR call_id !=$1 + WHERE phone NOT IN ( + SELECT phone FROM invitations + WHERE call_id=$1 ) - ORDER BY group_num LIMIT $2`, - callID, num) + AND age<=$2 + AND age>=$3 + AND status=0 + ORDER BY group_num LIMIT $4`, + callID, call.Call.AgeMax, call.Call.AgeMin, num) if err != nil { log.Error(err) return persons, err @@ -432,17 +461,57 @@ func (b *Bridge) GetPersons() ([]Person, error) { return persons, err } +type Invitation struct { + ID int `db:"id"` + Phone string `db:"phone"` + CallID int `db:"call_id"` + Status invitationStatus `db:"status"` + Time time.Time `db:"time"` +} + +type invitationStatus string + +const ( + InvitationRejected invitationStatus = "rejected" + InvitationAccepted invitationStatus = "accepted" + InvitationCancelled invitationStatus = "cancelled" + InvitationNotified invitationStatus = "notified" +) + +func (b *Bridge) GetInvitations() ([]Invitation, error) { + + log.Debug("Retrieving invitations") + + invitations := []Invitation{} + err := b.db.Select(&invitations, "SELECT * FROM invitations ORDER BY time DESC") + if err != nil { + log.Error(err) + return invitations, err + } + + // log.Debugf("Found invitations: %+v\n", invitations) + return invitations, err +} + // CallFull is true if the call is full. Used to check call status func (b *Bridge) CallFull(call Call) (bool, error) { + var numAccpets int - err := b.db.Get(numAccpets, "select count(id) from invitations where call_id=$1 and status='accepted'", call.ID) + err := b.db.Get(&numAccpets, "select count(id) from invitations where call_id=$1 and status='accepted'", call.ID) + log.Debugf("Checking full status for call %v: %v/%v", call.ID, numAccpets, call.Capacity) return numAccpets >= call.Capacity, err } // LastCallNotified retrieves the last call a person was notified to func (b *Bridge) LastCallNotified(person Person) (Call, error) { lastCallOfPerson := Call{} - err := b.db.Get(&lastCallOfPerson, "select calls.* from calls join invitations where invitations.phone=$1 and invitations.status = \"accepted\" order by invitations.time desc limit 1", person.Phone) + err := b.db.Get(&lastCallOfPerson, + `SELECT * FROM calls + WHERE id = ( + SELECT call_id FROM invitations + WHERE phone=$1 + ORDER BY time DESC + )`, person.Phone) return lastCallOfPerson, err } @@ -453,63 +522,126 @@ func (b *Bridge) PersonAcceptLastCall(phoneNumber string) error { log.Debugf("number %s trying to accept call\n", phoneNumber) - lastCall, err := b.LastCallNotified(Person{Phone: phoneNumber}) + var err error + var lastCall Call + var isFull bool - if err != nil { + if lastCall, err = b.LastCallNotified(Person{Phone: phoneNumber}); err != nil { + log.Debugf("Phone %s has not been invited yet\n", phoneNumber) return err } - isFull, err := b.CallFull(lastCall) - - if err != nil { + if isFull, err = b.CallFull(lastCall); err != nil { + log.Debugf("Last call %v, does not exist\n", lastCall.ID) return err } if isFull { - log.Debugf("number %s rejected for call (is full)\n", phoneNumber) + log.Debugf("Rejecting number %s for call (is full)\n", phoneNumber) + _, err = bridge.db.NamedExec( + `UPDATE invitations SET + status=:status, + time=:time + WHERE + phone=:phone + AND call_id=:call_id + AND status=:oldstatus`, + map[string]interface{}{ + "status": InvitationAccepted, + "oldstatus": InvitationRejected, + "phone": phoneNumber, + "time": time.Now(), + "call_id": lastCall.ID, + }, + ) + + if err != nil { + log.Errorf("Failed to set accepted status for last invitation of %s\n", phoneNumber) + return err + } + if err := b.sender.SendMessageReject(phoneNumber); err != nil { + log.Errorf("Failed to send reject message for phone %s\n", phoneNumber) log.Error(err) } } else { log.Debugf("Accepting number %s for call \n", phoneNumber) - if err = b.sender.SendMessageAccept( - phoneNumber, - lastCall.TimeStart.Format("14:12"), - lastCall.TimeEnd.Format("14:12"), - lastCall.LocName, - lastCall.LocStreet, - lastCall.LocHouseNr, - lastCall.LocPLZ, - lastCall.LocCity, - lastCall.LocOpt, - genOTP(phoneNumber, lastCall.ID), - ); err != nil { - return err - } - - _, err = bridge.db.NamedExec( - `UPDATE invitations SET status = "accepted", time=:time WHERE phone=:phone `, //TODO Test + log.Debugf("Setting status=accepted for phone %s\n", phoneNumber) + + var res sql.Result + res, err = bridge.db.NamedExec( + `UPDATE invitations SET + status=:status, + time=:time + WHERE + phone=:phone + AND call_id=:call_id + AND status=:oldstatus`, map[string]interface{}{ - "phone": phoneNumber, - "time": time.Now(), + "status": InvitationAccepted, + "oldstatus": InvitationNotified, + "phone": phoneNumber, + "time": time.Now(), + "call_id": lastCall.ID, }, ) + + if err != nil { + log.Errorf("Failed to set accepted status for last invitation of %s\n", phoneNumber) + return err + } + + rowNum, err := res.RowsAffected() + + if err != nil { + log.Error("Failed to get number of affected invitations") + return err + } + + log.Debugf("Updated %v invitations\n", rowNum) + + if rowNum == 0 { + log.Warn("No invitations updated, call might have been already accepted") + } else { + + log.Debugf("Sending accept message to phone %s\n", phoneNumber) + + if err = b.sender.SendMessageAccept( + phoneNumber, + lastCall.TimeStart.Format("14:12"), + lastCall.TimeEnd.Format("14:12"), + lastCall.LocName, + lastCall.LocStreet, + lastCall.LocHouseNr, + lastCall.LocPLZ, + lastCall.LocCity, + lastCall.LocOpt, + genOTP(phoneNumber, lastCall.ID), + ); err != nil { + log.Errorf("Failed to send accept message for phone %s: %v\n", phoneNumber, err) + return err + } + } } return err } -// PersonCancelCall cancels the last call a person was invited to -func (b *Bridge) PersonCancelCall(phoneNumber string) error { +// PersonCancelAllCalls cancels all accepted calls +func (b *Bridge) PersonCancelAllCalls(phoneNumber string) error { log.Debugf("Cancelling call for number %s\n", phoneNumber) _, err := bridge.db.NamedExec( - `UPDATE invitations SET status = "cancelled" WHERE phone=:phone`, + `UPDATE invitations SET status=:newstatus, time=:time WHERE phone=:phone AND status=:oldstatus`, + map[string]interface{}{ - "phone": phoneNumber, + "phone": phoneNumber, + "oldstatus": InvitationAccepted, + "newstatus": InvitationCancelled, + "time": time.Now(), }, ) @@ -527,15 +659,18 @@ func (b *Bridge) PersonDelete(phoneNumber string) error { result, err := b.db.NamedExec("DELETE FROM persons WHERE phone=:phone", m) if err != nil { + log.Warnf("Phone %s not deleted %v\n", phoneNumber, err) return err } numrows, err := result.RowsAffected() if err != nil { + log.Warnf("Failed to get rows affected by deletion %v\n", err) return err } if err := b.sender.SendMessageDelete(phoneNumber); err != nil { + log.Warnf("Failed to send deletion confirmation to %s: %v\n", phoneNumber, err) return err } diff --git a/bridge_test.go b/bridge_test.go index 3717426..d743fcc 100644 --- a/bridge_test.go +++ b/bridge_test.go @@ -1,9 +1,14 @@ package main import ( + "bytes" "fmt" + "io/ioutil" + "net/http" "os" "reflect" + "strconv" + "strings" "testing" "time" @@ -14,17 +19,177 @@ import ( _ "github.com/mattn/go-sqlite3" ) +// Custom type that allows setting the func that our Mock Do func will run +// instead +type MockClient struct { + MockDo func(req *http.Request) (*http.Response, error) // MockClient is the mock client +} + +// Overriding what the Do function should "do" in our MockClient +func (m *MockClient) Do(req *http.Request) (*http.Response, error) { + return m.MockDo(req) +} + var ( fixtures *testfixtures.Loader sender *TwillioSender + loc = time.FixedZone("+0100", 3600) + + fakeNow time.Time = time.Date(2999, 1, 1, 20, 0, 0, 0, time.UTC) + + fixtureCalls []Call = []Call{ + { + ID: 1, + Title: "Call number 1", + Capacity: 1, + TimeStart: time.Date(2021, time.February, 10, 12, 30, 0, 0, loc), + TimeEnd: time.Date(2021, time.February, 10, 12, 35, 0, 0, loc), + AgeMin: 0, + AgeMax: 100, + LocName: "loc_name1", + LocStreet: "loc_street1", + LocHouseNr: "loc_housenr1", + LocPLZ: "loc_plz1", + LocCity: "loc_city1", + LocOpt: "loc_opt1", + }, + { + ID: 2, + Title: "Call number 2", + Capacity: 2, + TimeStart: time.Date(2021, time.February, 10, 12, 31, 0, 0, loc), + TimeEnd: time.Date(2021, time.February, 10, 12, 36, 0, 0, loc), + AgeMin: 0, + AgeMax: 70, + LocName: "loc_name2", + LocStreet: "loc_street2", + LocHouseNr: "loc_housenr2", + LocPLZ: "loc_plz2", + LocCity: "loc_city2", + }, + { + ID: 3, + Title: "Call number 3", + Capacity: 3, + TimeStart: time.Date(2021, time.January, 1, 12, 30, 0, 0, loc), + TimeEnd: time.Date(2021, time.January, 1, 12, 35, 0, 0, loc), + AgeMin: 70, + AgeMax: 200, + LocName: "loc_name3", + LocStreet: "loc_street3", + LocHouseNr: "loc_housenr3", + LocPLZ: "loc_plz3", + LocCity: "loc_city3", + LocOpt: "loc_opt3", + }, + } + fixturePersons []Person = []Person{ + { + Phone: "1230", + CenterID: 0, + Group: 1, + Age: 10, + Status: false, + }, + { + Phone: "1231", + CenterID: 0, + Group: 2, + Age: 70, + Status: false, + }, + { + Phone: "1232", + CenterID: 0, + Group: 1, + Age: 150, + Status: true, + }, + } + + fixtureInvitations []Invitation = []Invitation{ + { + Phone: "1230", + CallID: 1, + Status: "accepted", + Time: time.Date(2021, 2, 10, 12, 36, 0, 0, loc), + }, + { + ID: 1, + Phone: "1231", + CallID: 1, + Status: "accepted", + Time: time.Date(2021, 2, 10, 12, 36, 0, 0, loc), + }, + { + ID: 2, + Phone: "1232", + CallID: 2, + Status: "rejected", + Time: time.Date(2021, 2, 10, 12, 36, 0, 0, loc), + }, + } ) +var HTTPResponse string + +// formatRequest generates ascii representation of a request +func formatRequest(r *http.Request) string { + // Create return string + var request []string // Add the request string + url := fmt.Sprintf("%v %v %v", r.Method, r.URL, r.Proto) + request = append(request, url) // Add the host + request = append(request, fmt.Sprintf("Host: %v", r.Host)) // Loop through headers + for name, headers := range r.Header { + name = strings.ToLower(name) + for _, h := range headers { + request = append(request, fmt.Sprintf("%v: %v", name, h)) + } + } + + // If this is a POST, add post data + if r.Method == "POST" { + if err := r.ParseForm(); err != nil { + panic(err) + } + // request = append(request, "Formdata:") + request = append(request, r.Form.Encode()) + } // Return the request as a string + return strings.Join(request, "\n") +} + func TestMain(m *testing.M) { + Client = &MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + fmt.Printf("Faking request: \n%s\n", formatRequest(req)) + fmt.Printf("Faking response: %s\n", HTTPResponse) + return &http.Response{ + StatusCode: 200, + Body: ioutil.NopCloser(bytes.NewReader([]byte(HTTPResponse))), + }, nil + }, + } + // Fix current time inside tests to: // 2021-01-01 20:00:00 +0000 UTC - monkey.Patch(time.Now, func() time.Time { return time.Date(2021, 1, 1, 20, 0, 0, 0, time.UTC) }) - fmt.Println("Time is now ", time.Now()) + monkey.Patch(time.Now, func() time.Time { return time.Date(2999, 1, 1, 20, 0, 0, 0, time.UTC) }) + fmt.Println("Time is now fixed to:", time.Now()) + + os.Exit(m.Run()) +} + +func prepareTestDatabase(fixtureFiles ...string) { + + os.Setenv("IMPF_DISABLE_SMS", "") + os.Setenv("IMPF_MODE", "DEVEL") + os.Setenv("IMPF_SESSION_SECRET", "session_secret") + os.Setenv("IMPF_TWILIO_API_ENDPOINT", "https://studio.twilio.com/v2/Flows/") + os.Setenv("IMPF_TWILIO_API_FROM", "twilio_api_from") + os.Setenv("IMPF_TWILIO_API_PASS", "twilio_api_pass") + os.Setenv("IMPF_TWILIO_API_USER", "twilio_api_user") + os.Setenv("IMPF_TWILIO_PASS", "twilio_pass") + os.Setenv("IMPF_TWILIO_USER", "twilio_user") var err error @@ -51,10 +216,13 @@ func TestMain(m *testing.M) { db.MustExec(schemaUsers) db.MustExec(schemaNotifications) - fmt.Println("creating sender") - sender = NewTwillioSender("test", "test", "test", "test") + sender = NewTwillioSender( + os.Getenv("IMPF_TWILIO_API_ENDPOINT"), + os.Getenv("IMPF_TWILIO_API_USER"), + os.Getenv("IMPF_TWILIO_API_PASS"), + os.Getenv("IMPF_TWILIO_API_FROM"), + ) - fmt.Println("creating bridge") bridge = &Bridge{ db: db, sender: sender, @@ -64,22 +232,24 @@ func TestMain(m *testing.M) { // Do NOT import fixtures in a production database! // Existing data would be deleted. - fmt.Println("creating fixtures") + // Set default fixtures if none where specified + if len(fixtureFiles) == 0 { + fixtureFiles = []string{ + "testdata/fixtures/calls.yml", + "testdata/fixtures/invitations.yml", + "testdata/fixtures/persons.yml", + } + } + fixtures, err = testfixtures.New( - testfixtures.Database(db.DB), // You database connection - testfixtures.Dialect("sqlite"), // Available: "postgresql", "timescaledb", "mysql", "mariadb", "sqlite" and "sqlserver" - testfixtures.Files("./testdata/fixtures/persons.yml"), // the directory containing the YAML files - testfixtures.Files("./testdata/fixtures/invitations.yml"), // the directory containing the YAML files - testfixtures.Files("./testdata/fixtures/calls.yml"), // the directory containing the YAML files + testfixtures.Database(db.DB), // You database connection + testfixtures.Dialect("sqlite"), // Available: "postgresql", "timescaledb", "mysql", "mariadb", "sqlite" and "sqlserver" + testfixtures.Files(fixtureFiles...), // the directory containing the YAML files ) if err != nil { panic(err) } - os.Exit(m.Run()) -} - -func prepareTestDatabase() { if err := fixtures.Load(); err != nil { fmt.Println("Loading fixtures") panic(err) @@ -97,12 +267,8 @@ func TestBridge_GetPersons(t *testing.T) { wantErr bool }{ { - name: "Retrieve persons from DB", - want: []Person{ - {Phone: "1230", CenterID: 0, Group: 1, Status: false}, - {Phone: "1231", CenterID: 0, Group: 1, Status: false}, - {Phone: "1232", CenterID: 0, Group: 1, Status: false}, - }, + name: "Retrieve persons from DB", + want: fixturePersons, wantErr: false, }, } @@ -134,8 +300,8 @@ func TestBridge_GetAcceptedPersons(t *testing.T) { name: "Call with accepted invitations", id: 1, want: []Person{ - {Phone: "1230", Group: 1}, - {Phone: "1231", Group: 1}, + fixturePersons[0], + fixturePersons[1], }, wantErr: false, }, @@ -181,12 +347,13 @@ func TestBridge_AddPerson(t *testing.T) { CenterID: 0, Group: 1, Status: false, + Age: 80, }, want: []Person{ - {Phone: "1230", Group: 1}, - {Phone: "1231", Group: 1}, - {Phone: "1232", Group: 1}, - {"0001", 0, 1, false}, + fixturePersons[0], + fixturePersons[1], + fixturePersons[2], + {"0001", 0, 1, false, 80}, }, wantErr: false, }, } @@ -220,15 +387,16 @@ func TestBridge_AddPersons(t *testing.T) { { name: "Add two persons", persons: []Person{ - {"0001", 0, 1, false}, - {"0002", 0, 1, false}, + {"0001", 0, 1, false, 40}, + {"0002", 0, 1, false, 90}, }, want: []Person{ - {Phone: "1230", Group: 1}, - {Phone: "1231", Group: 1}, - {Phone: "1232", Group: 1}, - {"0001", 0, 1, false}, - {"0002", 0, 1, false}, + + fixturePersons[0], + fixturePersons[1], + fixturePersons[2], + {"0001", 0, 1, false, 40}, + {"0002", 0, 1, false, 90}, }, wantErr: false, }, @@ -255,8 +423,6 @@ func TestBridge_GetCallStatus(t *testing.T) { prepareTestDatabase() - loc := time.FixedZone("myzone", 3600) - tests := []struct { name string id string @@ -267,24 +433,10 @@ func TestBridge_GetCallStatus(t *testing.T) { name: "Get a valid callstatus", id: "1", want: CallStatus{ - Call: Call{ - ID: 1, - Title: "Call number 1", - CenterID: 0, - Capacity: 1, - TimeStart: time.Date(2021, time.February, 10, 12, 30, 0, 0, loc), - TimeEnd: time.Date(2021, time.February, 10, 12, 35, 0, 0, loc), - LocName: "loc_name1", - LocStreet: "loc_street1", - LocHouseNr: "loc_housenr1", - YoungOnly: true, - LocPLZ: "loc_plz1", - LocCity: "loc_city1", - LocOpt: "loc_opt1", - }, + Call: fixtureCalls[0], Persons: []Person{ - {"1230", 0, 1, false}, - {"1231", 0, 1, false}, + fixturePersons[0], + fixturePersons[1], }, }, wantErr: false, @@ -307,9 +459,13 @@ func TestBridge_GetCallStatus(t *testing.T) { func TestBridge_GetActiveCalls(t *testing.T) { - prepareTestDatabase() + prepareTestDatabase("testdata/fixtures/TestBridge_GetActiveCalls/calls.yml") - loc := time.FixedZone("myzone", 3600) + calls, err := bridge.GetAllCalls() + + if err != nil { + panic(err) + } tests := []struct { name string @@ -317,36 +473,8 @@ func TestBridge_GetActiveCalls(t *testing.T) { wantErr bool }{ { - name: "Get two active calls", - want: []Call{ - - { - ID: 1, - Title: "Call number 1", - Capacity: 1, - TimeStart: time.Date(2021, time.February, 10, 12, 30, 0, 0, loc), - TimeEnd: time.Date(2021, time.February, 10, 12, 35, 0, 0, loc), - YoungOnly: true, - LocName: "loc_name1", - LocStreet: "loc_street1", - LocHouseNr: "loc_housenr1", - LocPLZ: "loc_plz1", - LocCity: "loc_city1", - LocOpt: "loc_opt1", - }, - { - ID: 2, - Title: "Call number 2", - Capacity: 2, - TimeStart: time.Date(2021, time.February, 10, 12, 31, 0, 0, loc), - TimeEnd: time.Date(2021, time.February, 10, 12, 36, 0, 0, loc), - LocName: "loc_name2", - LocStreet: "loc_street2", - LocHouseNr: "loc_housenr2", - LocPLZ: "loc_plz2", - LocCity: "loc_city2", - }, - }, + name: "Get two active calls", + want: []Call{calls[0], calls[1]}, wantErr: false, }, } @@ -366,37 +494,148 @@ func TestBridge_GetActiveCalls(t *testing.T) { } func TestBridge_GetNextPersonsForCall(t *testing.T) { - type fields struct { - db *sqlx.DB - sender *TwillioSender + + var fixtures *testfixtures.Loader + var err error + var allPersons []Person + + fixtures, err = testfixtures.New( + testfixtures.Database(bridge.db.DB), // You database connection + testfixtures.Dialect("sqlite"), // Available: "postgresql", "timescaledb", "mysql", "mariadb", "sqlite" and "sqlserver" + testfixtures.Files("./testdata/fixtures/getNextPersonsForCall/persons.yml"), // the directory containing the YAML files + testfixtures.Files("./testdata/fixtures/getNextPersonsForCall/invitations.yml"), // the directory containing the YAML files + testfixtures.Files("./testdata/fixtures/getNextPersonsForCall/calls.yml"), // the directory containing the YAML files + ) + if err != nil { + panic(err) } - type args struct { - num int - callID int + + allPersons, err = bridge.GetPersons() + if err != nil { + panic(err) + } + + if err := fixtures.Load(); err != nil { + fmt.Println("Loading fixtures") + panic(err) } tests := []struct { name string - fields fields - args args - want []Person + num int + callID int wantErr bool }{ - // TODO: Add test cases. + // 11 persons total in fixtures + // call ID 0: no age restriction + // call ID 1: withage restriction + {"Call without age restriction, 5/11 persons", 5, 0, false}, + {"Call without age restriction, 20/11 persons", 20, 0, false}, + {"Call with age restriction, 5/11 persons", 5, 1, false}, + {"Call with age restriction, 20/11 persons", 20, 1, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := &Bridge{ - db: tt.fields.db, - sender: tt.fields.sender, - } - got, err := b.GetNextPersonsForCall(tt.args.num, tt.args.callID) + got, err := bridge.GetNextPersonsForCall(tt.num, tt.callID) if (err != nil) != tt.wantErr { t.Errorf("Bridge.GetNextPersonsForCall() error = %v, wantErr %v", err, tt.wantErr) return } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Bridge.GetNextPersonsForCall() = %v, want %v", got, tt.want) + + // Get the call with the ID specified + call, err := bridge.GetCallStatus(strconv.Itoa(tt.callID)) + if err != nil { + panic(err) + } + + invitations, err := bridge.GetInvitations() + if err != nil { + panic(err) + } + + // Find highest selected group in results + highestGroup := 0 + for _, v := range got { + if v.Group > highestGroup { + highestGroup = v.Group + } } + + lowerPersonNum := 0 + // Find how many persons are in the DB with group number under highestGroup + for _, v := range allPersons { + if v.Group > highestGroup { + lowerPersonNum += 1 + } + } + + // Lower group numbers should always go first. If the number of + // persons with group nubmer below the highest returned group is + // greater or equal of to the number we are looking for, the result + // is wrong. A call should first be issued to alle the persons of + // lower groups before trying the next one up + if lowerPersonNum >= tt.num { + t.Error("Bridge.GetNextPersonsForCall selected persons with higher group than necessary") + return + } + + // if diff := cmp.Diff(tt.want, got); diff != "" { + // t.Errorf("Bridge.GetNextPersonsForCall() mismatch (-want +got):\n%s", diff) + // } + + // Check there are no duplicates in the persons returned. We place + // the returned slice in a map, with the phone as key and a + // arbitrary value (1). For each person, we try to get it by + // accessing the map, if it fails, we add it (this is good and + // means the person was not yet in the map). If it succeds we exit + // with an error, this means we tried to access a key (phonenumber) + // that already exists in the map, meaning that it is duplicate + duplicate_frequency := make(map[string]int) + for _, pers := range got { + if _, ok := duplicate_frequency[pers.Phone]; ok { + t.Error("Bridge.GetNextPersonsForCall returned duplicates") + } else { + duplicate_frequency[pers.Phone] = 1 + } + } + + // Check if less than the requested number of persons where + // returned, even though we had enough to choose from + if len(got) < tt.num && len(allPersons) > tt.num { + t.Error("Bridge.GetNextPersonsForCall returned not enough persons, even though available") + return + } + + // Check the number of persons returned does not exist the number requested + if len(got) > tt.num { + t.Error("Bridge.GetNextPersonsForCall returned too many persons") + return + } + + for _, v := range got { + // check none is already vaccinated + if v.Status { + t.Errorf("Bridge.GetNextPersonsForCall returned already vaccinated person Phone: %v\n", v.Phone) + return + } + + // check if all persons age criteria + if v.Age > call.Call.AgeMax || v.Age < call.Call.AgeMin { + t.Errorf("Bridge.GetNextPersonsForCall returned person outside of allowed age range, phone: %v\n", v.Phone) + return + } + } + + // check not already notified persons are retrieved + for _, i := range invitations { + for _, p := range got { + if i.Phone == p.Phone && i.CallID == tt.callID { + t.Errorf("Bridge.GetNextPersonsForCall returned phone %s already notified for call: %v\n", p.Phone, tt.callID) + return + } + } + } + + // TODO check: order within one group should be randomized }) } } @@ -418,24 +657,37 @@ func TestNewBridge(t *testing.T) { } func TestBridge_DeleteOldCalls(t *testing.T) { - type fields struct { - db *sqlx.DB - sender *TwillioSender + + prepareTestDatabase("testdata/fixtures/TestBridge_DeleteOldCalls/calls.yml") + + calls, err := bridge.GetAllCalls() + if err != nil { + panic(err) } + tests := []struct { - name string - fields fields + name string + want []Call }{ - // TODO: Add test cases. + { + name: "Get all calls after running deletion", + want: []Call{calls[2]}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := Bridge{ - db: tt.fields.db, - sender: tt.fields.sender, - } - b.DeleteOldCalls() + bridge.DeleteOldCalls() }) + + got, err := bridge.GetAllCalls() + if err != nil { + t.Errorf("Bridge.GetAllCalls() error = %v", err) + return + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("Bridge.GetActive() mismatch (-want +got):\n%s", diff) + } } } @@ -462,29 +714,21 @@ func TestBridge_SendNotifications(t *testing.T) { } func TestBridge_NotifyCall(t *testing.T) { - type fields struct { - db *sqlx.DB - sender *TwillioSender - } - type args struct { - id int - numPersons int - } + + prepareTestDatabase() + tests := []struct { - name string - fields fields - args args - wantErr bool + name string + callID int + numPersons int + wantErr bool }{ + // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := &Bridge{ - db: tt.fields.db, - sender: tt.fields.sender, - } - if err := b.NotifyCall(tt.args.id, tt.args.numPersons); (err != nil) != tt.wantErr { + if err := bridge.NotifyCall(tt.callID, tt.numPersons); (err != nil) != tt.wantErr { t.Errorf("Bridge.NotifyCall() error = %v, wantErr %v", err, tt.wantErr) } }) @@ -492,29 +736,21 @@ func TestBridge_NotifyCall(t *testing.T) { } func TestBridge_CallFull(t *testing.T) { - type fields struct { - db *sqlx.DB - sender *TwillioSender - } - type args struct { - call Call - } + prepareTestDatabase() + tests := []struct { name string - fields fields - args args + call Call want bool wantErr bool }{ - // TODO: Add test cases. + {"Get full call (ID:1)", fixtureCalls[0], true, false}, + {"Get not full call (ID:2)", fixtureCalls[1], false, false}, + {"Get not full call (ID:3)", fixtureCalls[2], false, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := &Bridge{ - db: tt.fields.db, - sender: tt.fields.sender, - } - got, err := b.CallFull(tt.args.call) + got, err := bridge.CallFull(tt.call) if (err != nil) != tt.wantErr { t.Errorf("Bridge.CallFull() error = %v, wantErr %v", err, tt.wantErr) return @@ -527,76 +763,144 @@ func TestBridge_CallFull(t *testing.T) { } func TestBridge_LastCallNotified(t *testing.T) { - type fields struct { - db *sqlx.DB - sender *TwillioSender - } - type args struct { - person Person - } + + prepareTestDatabase( + "testdata/fixtures/TestBridge_LastCallNotified/invitations.yml", + "testdata/fixtures/TestBridge_LastCallNotified/calls.yml") + tests := []struct { name string - fields fields - args args + person Person want Call wantErr bool }{ - // TODO: Add test cases. + {"Phone 0", Person{Phone: "0"}, fixtureCalls[1], false}, + {"Phone 1", Person{Phone: "1"}, fixtureCalls[2], false}, + {"Phone 2", Person{Phone: "2"}, fixtureCalls[1], false}, + {"Phone 3", Person{Phone: "3"}, fixtureCalls[1], false}, + {"Phone 4", Person{Phone: "4"}, fixtureCalls[1], false}, + {"Phone noexist", Person{Phone: "noexist"}, Call{}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := &Bridge{ - db: tt.fields.db, - sender: tt.fields.sender, - } - got, err := b.LastCallNotified(tt.args.person) + got, err := bridge.LastCallNotified(tt.person) if (err != nil) != tt.wantErr { t.Errorf("Bridge.LastCallNotified() error = %v, wantErr %v", err, tt.wantErr) return } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Bridge.LastCallNotified() = %v, want %v", got, tt.want) + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("Bridge.LastCallNotified() mismatch (-want +got):\n%s", diff) } }) } } func TestBridge_PersonAcceptLastCall(t *testing.T) { - type fields struct { - db *sqlx.DB - sender *TwillioSender + + prepareTestDatabase( + "testdata/fixtures/TestBridge_PersonAcceptLastCall/invitations.yml", + "testdata/fixtures/TestBridge_PersonAcceptLastCall/calls.yml", + ) + + gotBefore, err := bridge.GetInvitations() + + if err != nil { + panic(err) } - type args struct { + + tests := []struct { + name string phoneNumber string + want []Invitation + wantErr bool + }{ + {"Phone 1230", "1230", gotBefore, false}, + {"Phone 1231", "1231", []Invitation{ + { + ID: 1, + Phone: "1231", + CallID: 1, + Status: InvitationAccepted, + Time: time.Now(), + }, + gotBefore[0], + gotBefore[2], + gotBefore[3], + gotBefore[4], + }, false}, + {"Phone 1232", "1232", gotBefore, false}, + {"Phone noexist", "noexist", gotBefore, true}, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + // Reset database to initial values on each test, since the tests + // change the contents + prepareTestDatabase( + "testdata/fixtures/TestBridge_PersonAcceptLastCall/invitations.yml", + "testdata/fixtures/TestBridge_PersonAcceptLastCall/calls.yml", + ) + + if err := bridge.PersonAcceptLastCall(tt.phoneNumber); (err != nil) != tt.wantErr { + t.Errorf("Bridge.PersonAcceptLastCall() error = %v, wantErr %v", err, tt.wantErr) + } + + gotAfter, err := bridge.GetInvitations() + if err != nil { + panic(err) + } + + if diff := cmp.Diff(tt.want, gotAfter); diff != "" { + t.Errorf("Bridge.GetInvitations() after PersonAcceptLastCall(%s) mismatch (-want +got):\n%s", tt.phoneNumber, diff) + } + }) + } +} + +func TestBridge_PersonDelete(t *testing.T) { + + prepareTestDatabase() + tests := []struct { - name string - fields fields - args args - wantErr bool + name string + phoneNumber string + want []Person + wantErr bool }{ + {"Remove 1230", "1230", []Person{fixturePersons[1], fixturePersons[2]}, false}, + {"Remove 1231", "1231", []Person{fixturePersons[2]}, false}, + {"Remove 1232", "1232", []Person{}, false}, // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := &Bridge{ - db: tt.fields.db, - sender: tt.fields.sender, + + if err := bridge.PersonDelete(tt.phoneNumber); (err != nil) != tt.wantErr { + t.Errorf("Bridge.PersonDelete() error = %v, wantErr %v", err, tt.wantErr) } - if err := b.PersonAcceptLastCall(tt.args.phoneNumber); (err != nil) != tt.wantErr { - t.Errorf("Bridge.PersonAcceptLastCall() error = %v, wantErr %v", err, tt.wantErr) + + got, err := bridge.GetPersons() + if (err != nil) != tt.wantErr { + t.Errorf("Bridge.GetPersons() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("Bridge.GetPersons() after deleting mismatch (-want +got):\n%s", diff) } + }) } } -func TestBridge_PersonCancelCall(t *testing.T) { +func TestBridge_AddCall(t *testing.T) { type fields struct { db *sqlx.DB sender *TwillioSender } type args struct { - phoneNumber string + call Call } tests := []struct { name string @@ -612,38 +916,199 @@ func TestBridge_PersonCancelCall(t *testing.T) { db: tt.fields.db, sender: tt.fields.sender, } - if err := b.PersonCancelCall(tt.args.phoneNumber); (err != nil) != tt.wantErr { - t.Errorf("Bridge.PersonCancelCall() error = %v, wantErr %v", err, tt.wantErr) + if err := b.AddCall(tt.args.call); (err != nil) != tt.wantErr { + t.Errorf("Bridge.AddCall() error = %v, wantErr %v", err, tt.wantErr) } }) } } -func TestBridge_PersonDelete(t *testing.T) { - type fields struct { - db *sqlx.DB - sender *TwillioSender +func TestBridge_GetAllCalls(t *testing.T) { + + prepareTestDatabase() + + tests := []struct { + name string + want []Call + wantErr bool + }{ + { + name: "Retrieve calls from DB", + want: fixtureCalls, + wantErr: false, + }, } - type args struct { - phoneNumber string + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := bridge.GetAllCalls() + if (err != nil) != tt.wantErr { + t.Errorf("Bridge.GetAllCalls() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("Bridge.GetAllCalls() mismatch (-want +got):\n%s", diff) + } + }) } +} + +func TestBridge_GetInvitations(t *testing.T) { + + prepareTestDatabase() + tests := []struct { name string - fields fields - args args + want []Invitation wantErr bool }{ - // TODO: Add test cases. + {"Retrieve all invitations from DB", fixtureInvitations, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := &Bridge{ - db: tt.fields.db, - sender: tt.fields.sender, + got, err := bridge.GetInvitations() + if (err != nil) != tt.wantErr { + t.Errorf("Bridge.GetInvitations() error = %v, wantErr %v", err, tt.wantErr) + return } - if err := b.PersonDelete(tt.args.phoneNumber); (err != nil) != tt.wantErr { - t.Errorf("Bridge.PersonDelete() error = %v, wantErr %v", err, tt.wantErr) + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("Bridge.GetInvitations() mismatch (-want +got):\n%s", diff) } }) } } + +func TestBridge_PersonCancelAllCalls(t *testing.T) { + prepareTestDatabase("testdata/fixtures/TestBridge_PersonCancelAllCalls/invitations.yml") + + gotBefore, err := bridge.GetInvitations() + if err != nil { + panic(err) + } + + tests := []struct { + name string + phoneNumber string + want []Invitation + wantErr bool + }{ + { + "Cancel Calls of phone 1230", + "1230", + []Invitation{ + { + ID: 0, + Phone: "1230", + CallID: 1, + Status: "cancelled", + Time: fakeNow, + }, + { + ID: 1, + Phone: "1230", + CallID: 2, + Status: "cancelled", + Time: fakeNow, + }, + gotBefore[2], + gotBefore[3], + gotBefore[4], + gotBefore[5], + gotBefore[6], + gotBefore[7], + }, + false}, + { + "Cancel Calls of phone 1231", + "1231", + []Invitation{ + { + ID: 0, + Phone: "1230", + CallID: 1, + Status: "cancelled", + Time: fakeNow, + }, + { + ID: 1, + Phone: "1230", + CallID: 2, + Status: "cancelled", + Time: fakeNow, + }, + + { + ID: 4, + Phone: "1231", + CallID: 1, + Status: "cancelled", + Time: fakeNow, + }, + { + ID: 5, + Phone: "1231", + CallID: 2, + Status: "cancelled", + Time: fakeNow, + }, + gotBefore[2], + gotBefore[3], + gotBefore[6], + gotBefore[7], + }, + false}, + {"Cancel Calls of phone noexist", "noexist", []Invitation{ + { + ID: 0, + Phone: "1230", + CallID: 1, + Status: "cancelled", + Time: fakeNow, + }, + { + ID: 1, + Phone: "1230", + CallID: 2, + Status: "cancelled", + Time: fakeNow, + }, + + { + ID: 4, + Phone: "1231", + CallID: 1, + Status: "cancelled", + Time: fakeNow, + }, + { + ID: 5, + Phone: "1231", + CallID: 2, + Status: "cancelled", + Time: fakeNow, + }, + gotBefore[2], + gotBefore[3], + gotBefore[6], + gotBefore[7], + }, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := bridge.PersonCancelAllCalls(tt.phoneNumber); (err != nil) != tt.wantErr { + t.Errorf("Bridge.PersonCancelAllCalls() error = %v, wantErr %v", err, tt.wantErr) + } + + got, err := bridge.GetInvitations() + if err != nil { + panic(err) + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("Bridge.GetInvitations() mismatch (-want +got):\n%s", diff) + } + + }) + } +} diff --git a/call.go b/call.go index 72977e8..2f6ea26 100644 --- a/call.go +++ b/call.go @@ -19,7 +19,8 @@ type Call struct { Capacity int `db:"capacity"` TimeStart time.Time `db:"time_start"` TimeEnd time.Time `db:"time_end"` - YoungOnly bool `db:"young_only"` + AgeMin int `db:"age_min"` + AgeMax int `db:"age_max"` LocName string `db:"loc_name"` LocStreet string `db:"loc_street"` LocHouseNr string `db:"loc_housenr"` @@ -56,6 +57,18 @@ func NewCall(data url.Values) (Call, []string, error) { retError = err } + ageMin, err := strconv.Atoi(data.Get("age_min")) + if err != nil || ageMin < 0 { + errorStrings = append(errorStrings, "Ungültiges Mindestalter") + retError = err + } + + ageMax, err := strconv.Atoi(data.Get("age_max")) + if err != nil || ageMax > 200 { + errorStrings = append(errorStrings, "Ungültiges Höchstalter") + retError = err + } + // Validate start and end times make sense log.Debug("start-time: ", data.Get("start-time")) log.Debug("end-time: ", data.Get("end-time")) @@ -79,7 +92,6 @@ func NewCall(data url.Values) (Call, []string, error) { // Get text fields and check that they are not empty strings var locName, locStreet, locHouseNr, locPlz, locCity, locOpt, title string - var youngOnly bool locName, errorStrings = getFormFieldWithErrors(data, "loc_name", errorStrings) locStreet, errorStrings = getFormFieldWithErrors(data, "loc_street", errorStrings) @@ -89,11 +101,6 @@ func NewCall(data url.Values) (Call, []string, error) { locOpt, errorStrings = getFormFieldWithErrors(data, "loc_opt", errorStrings) title, errorStrings = getFormFieldWithErrors(data, "title", errorStrings) - if youngOnly, err = strconv.ParseBool(data.Get("young_only")); err != nil { - errorStrings = append(errorStrings, "Ungültige Angabe für Impfstoff") - retError = err - } - if len(errorStrings) != 0 { retError = errors.New("Missing input data") } @@ -114,7 +121,8 @@ func NewCall(data url.Values) (Call, []string, error) { LocPLZ: locPlz, LocCity: locCity, LocOpt: locOpt, - YoungOnly: youngOnly, + AgeMin: ageMin, + AgeMax: ageMax, }, errorStrings, retError } diff --git a/go.sum b/go.sum index a8530a3..c8b3df1 100644 --- a/go.sum +++ b/go.sum @@ -166,6 +166,7 @@ golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= diff --git a/handler_api.go b/handler_api.go index b621cd3..ba54871 100644 --- a/handler_api.go +++ b/handler_api.go @@ -40,7 +40,7 @@ func handlerAPI(w http.ResponseWriter, r *http.Request) { header = http.StatusBadRequest } case "storno": - if err := bridge.PersonCancelCall(phoneNumber); err != nil { + if err := bridge.PersonCancelAllCalls(phoneNumber); err != nil { log.Error(err) header = http.StatusBadRequest } diff --git a/handler_person.go b/handler_person.go index b8754ac..cb88182 100644 --- a/handler_person.go +++ b/handler_person.go @@ -44,6 +44,7 @@ func handlerAddPerson(w http.ResponseWriter, r *http.Request) { data := r.Form phone := data.Get("phone") group := data.Get("group") + age := data.Get("age") // Try to create new call from input data groupNum, err := strconv.Atoi(group) @@ -57,6 +58,17 @@ func handlerAddPerson(w http.ResponseWriter, r *http.Request) { return } + ageNum, err := strconv.Atoi(age) + + if err != nil { + log.Debug(err) + tData.AppMessages = append(tData.AppMessages, "Ungültiges Alter") + if err := templates.ExecuteTemplate(w, "importPersons.html", tData); err != nil { + log.Error(err) + } + return + } + if phone == "" { tData.AppMessages = append(tData.AppMessages, "Fehlende Rufnummer") if err := templates.ExecuteTemplate(w, "importPersons.html", tData); err != nil { @@ -65,7 +77,7 @@ func handlerAddPerson(w http.ResponseWriter, r *http.Request) { return } - person, err := NewPerson(0, groupNum, phone, false) + person, err := NewPerson(0, groupNum, phone, false, ageNum) if err != nil { log.Debug(err) tData.AppMessages = append(tData.AppMessages, "Eingaben ungültig") diff --git a/handler_upload.go b/handler_upload.go index d14c1f7..1e15b29 100644 --- a/handler_upload.go +++ b/handler_upload.go @@ -61,9 +61,15 @@ func handlerUpload(w http.ResponseWriter, r *http.Request) { return } + ageNum, err := strconv.Atoi(record[2]) + if err != nil { + log.Warn(err) + return + } + // Try to create a new persion object from the data and return on // errors - p, err := NewPerson(0, groupNum, record[1], false) + p, err := NewPerson(0, groupNum, record[1], false, ageNum) if err != nil { log.Warn(err) return diff --git a/helper.go b/helper.go index 485361a..d861185 100644 --- a/helper.go +++ b/helper.go @@ -45,9 +45,10 @@ func contextString(key contextKey, r *http.Request) string { // genOTP generates a OTP to verify the person on-site. The OTP is the first 5 // chars of the SHA-1 hash of phonenumber+callID+tokenSecret func genOTP(phone string, callID int) string { + h := sha1.New() // Firt value are written bytes, we only care about the error if any - if _, err := h.Write([]byte(phone + strconv.Itoa(callID) + tokenSecret)); err != nil { + if _, err := h.Write([]byte(phone + strconv.Itoa(callID) + os.Getenv("IMPF_TOKEN_SECRET"))); err != nil { log.Error(err) } return hex.EncodeToString(h.Sum(nil))[1:5] diff --git a/main.go b/main.go index 8458b48..36a3200 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,15 @@ import ( "github.com/gorilla/sessions" ) +// HTTPClient interface +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +var ( + Client HTTPClient +) + var ( // Instance of the main application @@ -24,11 +33,9 @@ var ( store *sessions.CookieStore // API auth for twilio - apiUser string - apiPass string - tokenSecret string - disableSMS string - dbPath string + // apiUser string + // apiPass string + // dbPath string ) // User holds a users account information @@ -38,6 +45,7 @@ type User struct { } func init() { + Client = &http.Client{} store = sessions.NewCookieStore([]byte(os.Getenv("IMPF_SESSION_SECRET"))) @@ -57,15 +65,9 @@ func init() { } // Show more logs if IMPF_MODE=DEVEL is set - apiUser = os.Getenv("IMPF_TWILIO_USER") - apiPass = os.Getenv("IMPF_TWILIO_PASS") - tokenSecret = os.Getenv("IMPF_TOKEN_SECRET") - disableSMS = os.Getenv("IMPF_DISABLE_SMS") - dbPath = os.Getenv("IMPF_DB_FILE") - - // Add default if not set - if dbPath == "" { - dbPath = "./data.db" + if os.Getenv("IMPF_DB_FILE") == "" { + // Add default if not set + os.Setenv("IMPF_DB_FILE", "./data.db") } // Intial setup. Instanciate bridge and parse html templates @@ -117,6 +119,10 @@ func main() { } func middlewareAPI(h http.Handler) http.Handler { + + apiUser := os.Getenv("IMPF_TWILIO_USER") + apiPass := os.Getenv("IMPF_TWILIO_PASS") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() diff --git a/person.go b/person.go index 91a121e..0713a93 100644 --- a/person.go +++ b/person.go @@ -16,16 +16,18 @@ type Person struct { CenterID int `db:"center_id"` // ID of center that added this person Group int `db:"group_num"` // Vaccination group Status bool `db:"status"` // Vaccination status + Age int `db:"age"` // Age of the person, to determine compatible vaccines } // NewPerson receives the input data and returns a slice of person objects. For // single import this will just be an array with a single entry, for CSV upload // it may be longer. -func NewPerson(centerID, group int, phone string, status bool) (Person, error) { +func NewPerson(centerID, group int, phone string, status bool, age int) (Person, error) { person := Person{ CenterID: centerID, Status: status, + Age: age, } num, err := libphonenumber.Parse(phone, "DE") diff --git a/sender.go b/sender.go index 6471c5d..74ea285 100644 --- a/sender.go +++ b/sender.go @@ -1,6 +1,7 @@ package main import ( + "os" // "bytes" "encoding/json" "fmt" @@ -40,8 +41,12 @@ func (s TwillioSender) SendMessage(msgTo, msgBody string) error { data.Set("Parameters", string(jsonData)) + if os.Getenv("IMPF_DISABLE_SMS") != "" { + log.Info("SMS sending disabled. Unset IMPF_DISABLE_SMS to enable") + return nil + } + // Create a new client and request - client := &http.Client{} r, err := http.NewRequest("POST", s.endpoint, strings.NewReader(data.Encode())) // URL-encoded payload if err != nil { return err @@ -51,13 +56,8 @@ func (s TwillioSender) SendMessage(msgTo, msgBody string) error { r.Header.Add("Content-Type", "application/x-www-form-urlencoded") r.SetBasicAuth(s.user, s.token) - if disableSMS != "" { - log.Info("SMS sending disabled. Unset IMPF_DISABLE_SMS to enable") - return nil - } - // Execute the request - res, err := client.Do(r) + res, err := Client.Do(r) if err != nil { return err } diff --git a/testdata/fixtures/TestBridge_DeleteOldCalls/calls.yml b/testdata/fixtures/TestBridge_DeleteOldCalls/calls.yml new file mode 100644 index 0000000..a45b14a --- /dev/null +++ b/testdata/fixtures/TestBridge_DeleteOldCalls/calls.yml @@ -0,0 +1,44 @@ +- id: 1 + title: "Call number 1" + center_id: 0 + capacity: 1 + time_start: "2021-02-10 12:30:00+01:00" + time_end: "2021-02-10 12:35:00+01:00" + loc_name: "loc_name1" + age_min: 0 + age_max: 100 + loc_street: "loc_street1" + loc_housenr: "loc_housenr1" + loc_plz: "loc_plz1" + loc_city: "loc_city1" + loc_opt: "loc_opt1" + +- id: 2 + title: "Call number 2" + center_id: 0 + capacity: 2 + time_start: "2021-02-10 12:31:00+01:00" + time_end: "2021-02-10 12:36:00+01:00" + age_min: 0 + age_max: 70 + loc_name: "loc_name2" + loc_street: "loc_street2" + loc_housenr: "loc_housenr2" + loc_plz: "loc_plz2" + loc_city: "loc_city2" + loc_opt: "" + +- id: 3 + title: "Call number 3" + center_id: 0 + capacity: 3 + time_start: "2021-01-01 12:30:00+01:00" + time_end: "3000-01-01 12:35:00+01:00" + age_min: 70 + age_max: 200 + loc_name: "loc_name3" + loc_street: "loc_street3" + loc_housenr: "loc_housenr3" + loc_plz: "loc_plz3" + loc_city: "loc_city3" + loc_opt: "loc_opt3" diff --git a/testdata/fixtures/TestBridge_GetActiveCalls/calls.yml b/testdata/fixtures/TestBridge_GetActiveCalls/calls.yml new file mode 100644 index 0000000..a0f589e --- /dev/null +++ b/testdata/fixtures/TestBridge_GetActiveCalls/calls.yml @@ -0,0 +1,44 @@ +- id: 1 + title: "Call number 1" + center_id: 0 + capacity: 1 + time_start: "2021-02-10 12:30:00+01:00" + time_end: "3000-02-10 12:35:00+01:00" + loc_name: "loc_name1" + age_min: 0 + age_max: 100 + loc_street: "loc_street1" + loc_housenr: "loc_housenr1" + loc_plz: "loc_plz1" + loc_city: "loc_city1" + loc_opt: "loc_opt1" + +- id: 2 + title: "Call number 2" + center_id: 0 + capacity: 2 + time_start: "2021-02-10 12:31:00+01:00" + time_end: "3000-02-10 12:36:00+01:00" + age_min: 0 + age_max: 70 + loc_name: "loc_name2" + loc_street: "loc_street2" + loc_housenr: "loc_housenr2" + loc_plz: "loc_plz2" + loc_city: "loc_city2" + loc_opt: "" + +- id: 3 + title: "Call number 3" + center_id: 0 + capacity: 3 + time_start: "2021-01-01 12:30:00+01:00" + time_end: "2021-01-01 12:35:00+01:00" + age_min: 70 + age_max: 200 + loc_name: "loc_name3" + loc_street: "loc_street3" + loc_housenr: "loc_housenr3" + loc_plz: "loc_plz3" + loc_city: "loc_city3" + loc_opt: "loc_opt3" diff --git a/testdata/fixtures/TestBridge_LastCallNotified/calls.yml b/testdata/fixtures/TestBridge_LastCallNotified/calls.yml new file mode 100644 index 0000000..ac46cc9 --- /dev/null +++ b/testdata/fixtures/TestBridge_LastCallNotified/calls.yml @@ -0,0 +1,44 @@ +- id: 1 + title: "Call number 1" + center_id: 0 + capacity: 1 + time_start: "2021-02-10 12:30:00+01:00" + time_end: "2021-02-10 12:35:00+01:00" + loc_name: "loc_name1" + age_min: 0 + age_max: 100 + loc_street: "loc_street1" + loc_housenr: "loc_housenr1" + loc_plz: "loc_plz1" + loc_city: "loc_city1" + loc_opt: "loc_opt1" + +- id: 2 + title: "Call number 2" + center_id: 0 + capacity: 2 + time_start: "2021-02-10 12:31:00+01:00" + time_end: "2021-02-10 12:36:00+01:00" + age_min: 0 + age_max: 70 + loc_name: "loc_name2" + loc_street: "loc_street2" + loc_housenr: "loc_housenr2" + loc_plz: "loc_plz2" + loc_city: "loc_city2" + loc_opt: "" + +- id: 3 + title: "Call number 3" + center_id: 0 + capacity: 3 + time_start: "2021-01-01 12:30:00+01:00" + time_end: "2021-01-01 12:35:00+01:00" + age_min: 70 + age_max: 200 + loc_name: "loc_name3" + loc_street: "loc_street3" + loc_housenr: "loc_housenr3" + loc_plz: "loc_plz3" + loc_city: "loc_city3" + loc_opt: "loc_opt3" diff --git a/testdata/fixtures/TestBridge_LastCallNotified/invitations.yml b/testdata/fixtures/TestBridge_LastCallNotified/invitations.yml new file mode 100644 index 0000000..3a1a50e --- /dev/null +++ b/testdata/fixtures/TestBridge_LastCallNotified/invitations.yml @@ -0,0 +1,42 @@ +- id: 0 + phone: "0" + call_id: 1 + status: "notified" + time: "2021-02-10 12:00:00+01:00" + +- id: 1 + phone: "0" + call_id: 2 + status: "accepted" + time: "2021-02-10 12:01:00+01:00" + +- id: 2 + phone: "1" + call_id: 2 + status: "notified" + time: "2021-02-10 12:00:00+01:00" + +- id: 3 + phone: "1" + call_id: 3 + status: "notified" + time: "2021-02-10 12:05:00+01:00" + +- id: 4 + phone: "2" + call_id: 2 + status: "notified" + time: "2021-02-10 12:05:00+01:00" + +- id: 5 + phone: "3" + call_id: 2 + status: "rejected" + time: "2021-02-10 12:05:00+01:00" + +- id: 6 + phone: "4" + call_id: 2 + status: "accepted" + time: "2021-02-10 12:05:00+01:00" + diff --git a/testdata/fixtures/TestBridge_PersonAcceptLastCall/calls.yml b/testdata/fixtures/TestBridge_PersonAcceptLastCall/calls.yml new file mode 100644 index 0000000..e63cf30 --- /dev/null +++ b/testdata/fixtures/TestBridge_PersonAcceptLastCall/calls.yml @@ -0,0 +1,46 @@ + +- id: 1 + title: "Call number 1" + center_id: 0 + capacity: 5 + time_start: "2021-02-10 12:30:00+01:00" + time_end: "2021-02-10 12:35:00+01:00" + loc_name: "loc_name1" + age_min: 0 + age_max: 100 + loc_street: "loc_street1" + loc_housenr: "loc_housenr1" + loc_plz: "loc_plz1" + loc_city: "loc_city1" + loc_opt: "loc_opt1" + +- id: 2 + title: "Call number 2" + center_id: 0 + capacity: 5 + time_start: "2021-02-10 12:31:00+01:00" + time_end: "2021-02-10 12:36:00+01:00" + age_min: 0 + age_max: 70 + loc_name: "loc_name2" + loc_street: "loc_street2" + loc_housenr: "loc_housenr2" + loc_plz: "loc_plz2" + loc_city: "loc_city2" + loc_opt: "" + +- id: 3 + title: "Call number 3" + center_id: 0 + capacity: 5 + time_start: "2021-01-01 12:30:00+01:00" + time_end: "2021-01-01 12:35:00+01:00" + age_min: 70 + age_max: 200 + loc_name: "loc_name3" + loc_street: "loc_street3" + loc_housenr: "loc_housenr3" + loc_plz: "loc_plz3" + loc_city: "loc_city3" + loc_opt: "loc_opt3" + diff --git a/testdata/fixtures/TestBridge_PersonAcceptLastCall/invitations.yml b/testdata/fixtures/TestBridge_PersonAcceptLastCall/invitations.yml new file mode 100644 index 0000000..c7a80b3 --- /dev/null +++ b/testdata/fixtures/TestBridge_PersonAcceptLastCall/invitations.yml @@ -0,0 +1,29 @@ +- id: 0 + phone: "1230" + call_id: 1 + status: "accepted" + time: "2021-02-10 12:36:00+01:00" + +- id: 1 + phone: "1231" + call_id: 1 + status: "notified" + time: "2021-02-10 12:39:00+01:00" + +- id: 2 + phone: "1231" + call_id: 2 + status: "notified" + time: "2021-02-10 12:37:00+01:00" + +- id: 4 + phone: "1231" + call_id: 3 + status: "notified" + time: "2021-02-10 12:38:00+01:00" + +- id: 3 + phone: "1232" + call_id: 2 + status: "rejected" + time: "2021-02-10 12:40:00+01:00" diff --git a/testdata/fixtures/TestBridge_PersonCancelAllCalls/invitations.yml b/testdata/fixtures/TestBridge_PersonCancelAllCalls/invitations.yml new file mode 100644 index 0000000..f8c7224 --- /dev/null +++ b/testdata/fixtures/TestBridge_PersonCancelAllCalls/invitations.yml @@ -0,0 +1,48 @@ +- id: 0 + phone: "1230" + call_id: 1 + status: "accepted" + time: "2021-02-10 12:36:00+01:00" + +- id: 1 + phone: "1230" + call_id: 2 + status: "accepted" + time: "2021-02-10 12:36:00+01:00" + +- id: 2 + phone: "1230" + call_id: 4 + status: "rejected" + time: "2021-02-10 12:36:00+01:00" + +- id: 3 + phone: "1230" + call_id: 3 + status: "rejected" + time: "2021-02-10 12:36:00+01:00" + + +- id: 4 + phone: "1231" + call_id: 1 + status: "accepted" + time: "2021-02-10 12:36:00+01:00" + +- id: 5 + phone: "1231" + call_id: 2 + status: "accepted" + time: "2021-02-10 12:36:00+01:00" + +- id: 6 + phone: "1231" + call_id: 4 + status: "rejected" + time: "2021-02-10 12:36:00+01:00" + +- id: 7 + phone: "1231" + call_id: 3 + status: "rejected" + time: "2021-02-10 12:36:00+01:00" diff --git a/testdata/fixtures/calls.yml b/testdata/fixtures/calls.yml index be7bdb1..ac46cc9 100644 --- a/testdata/fixtures/calls.yml +++ b/testdata/fixtures/calls.yml @@ -5,7 +5,8 @@ time_start: "2021-02-10 12:30:00+01:00" time_end: "2021-02-10 12:35:00+01:00" loc_name: "loc_name1" - young_only: true + age_min: 0 + age_max: 100 loc_street: "loc_street1" loc_housenr: "loc_housenr1" loc_plz: "loc_plz1" @@ -18,7 +19,8 @@ capacity: 2 time_start: "2021-02-10 12:31:00+01:00" time_end: "2021-02-10 12:36:00+01:00" - young_only: false + age_min: 0 + age_max: 70 loc_name: "loc_name2" loc_street: "loc_street2" loc_housenr: "loc_housenr2" @@ -32,7 +34,8 @@ capacity: 3 time_start: "2021-01-01 12:30:00+01:00" time_end: "2021-01-01 12:35:00+01:00" - young_only: false + age_min: 70 + age_max: 200 loc_name: "loc_name3" loc_street: "loc_street3" loc_housenr: "loc_housenr3" diff --git a/testdata/fixtures/getNextPersonsForCall/calls.yml b/testdata/fixtures/getNextPersonsForCall/calls.yml new file mode 100644 index 0000000..466f5ca --- /dev/null +++ b/testdata/fixtures/getNextPersonsForCall/calls.yml @@ -0,0 +1,44 @@ +- id: 1 + title: "Call number 1" + center_id: 0 + capacity: 1 + time_start: "2021-02-10 12:30:00+01:00" + time_end: "2021-02-10 12:35:00+01:00" + loc_name: "loc_name1" + age_min: 0 + age_max: 65 + loc_street: "loc_street1" + loc_housenr: "loc_housenr1" + loc_plz: "loc_plz1" + loc_city: "loc_city1" + loc_opt: "loc_opt1" + +- id: 2 + title: "Call number 2" + center_id: 0 + capacity: 2 + time_start: "2021-02-10 12:31:00+01:00" + time_end: "2021-02-10 12:36:00+01:00" + age_min: 0 + age_max: 100 + loc_name: "loc_name2" + loc_street: "loc_street2" + loc_housenr: "loc_housenr2" + loc_plz: "loc_plz2" + loc_city: "loc_city2" + loc_opt: "" + +- id: 3 + title: "Call number 3" + center_id: 0 + capacity: 3 + time_start: "2021-01-01 12:30:00+01:00" + time_end: "2021-01-01 12:35:00+01:00" + age_min: 60 + age_max: 100 + loc_name: "loc_name3" + loc_street: "loc_street3" + loc_housenr: "loc_housenr3" + loc_plz: "loc_plz3" + loc_city: "loc_city3" + loc_opt: "loc_opt3" diff --git a/testdata/fixtures/getNextPersonsForCall/invitations.yml b/testdata/fixtures/getNextPersonsForCall/invitations.yml new file mode 100644 index 0000000..7883fd6 --- /dev/null +++ b/testdata/fixtures/getNextPersonsForCall/invitations.yml @@ -0,0 +1,17 @@ +- id: 0 + phone: "1230" + call_id: 1 + status: "accepted" + time: "2021-02-10 12:36:00+01:00" + +- id: 1 + phone: "1231" + call_id: 1 + status: "accepted" + time: "2021-02-10 12:36:00+01:00" + +- id: 2 + phone: "1232" + call_id: 2 + status: "rejected" + time: "2021-02-10 12:36:00+01:00" diff --git a/testdata/fixtures/getNextPersonsForCall/persons.yml b/testdata/fixtures/getNextPersonsForCall/persons.yml new file mode 100644 index 0000000..9705e89 --- /dev/null +++ b/testdata/fixtures/getNextPersonsForCall/persons.yml @@ -0,0 +1,65 @@ +- phone: "0" + center_id: 0 + age: 1 + group_num: 1 + status: 0 + +- phone: "10" + center_id: 0 + age: 20 + group_num: 1 + status: 1 + +- phone: "11" + center_id: 0 + age: 40 + group_num: 1 + status: 0 + +- phone: "1" + center_id: 0 + age: 60 + group_num: 1 + status: 0 + +- phone: "2" + center_id: 0 + age: 65 + group_num: 2 + status: 0 + +- phone: "3" + center_id: 0 + age: 70 + group_num: 2 + status: 0 + +- phone: "4" + center_id: 0 + age: 75 + group_num: 4 + status: 0 + +- phone: "7" + center_id: 0 + age: 80 + group_num: 4 + status: 0 + +- phone: "8" + center_id: 0 + age: 85 + group_num: 4 + status: 0 + +- phone: "5" + center_id: 0 + age: 90 + group_num: 4 + status: 0 + +- phone: "6" + center_id: 1 + age: 100 + group_num: 2 + status: 0 diff --git a/testdata/fixtures/persons.yml b/testdata/fixtures/persons.yml index 6b0a0c0..832f501 100644 --- a/testdata/fixtures/persons.yml +++ b/testdata/fixtures/persons.yml @@ -1,14 +1,17 @@ - phone: "1230" center_id: 0 + age: 10 group_num: 1 status: 0 - phone: "1231" center_id: 0 - group_num: 1 + age: 70 + group_num: 2 status: 0 - phone: "1232" center_id: 0 + age: 150 group_num: 1 - status: 0 + status: 1