Compare commits
10 commits
cdd29a7ae8
...
1747612b86
Author | SHA1 | Date | |
---|---|---|---|
|
1747612b86 | ||
|
3335cfe795 | ||
|
eaca9c1691 | ||
|
1cc687395c | ||
|
0bd05b6efe | ||
|
045c8760fe | ||
|
8087e91d69 | ||
|
7cd86ed429 | ||
|
1918740563 | ||
|
7dae3def51 |
22 changed files with 528 additions and 34 deletions
|
@ -9,11 +9,12 @@ check-generate:
|
||||||
script:
|
script:
|
||||||
- nix-shell --run "make generate && git diff --quiet"
|
- nix-shell --run "make generate && git diff --quiet"
|
||||||
|
|
||||||
check-fmt:
|
check-lint:
|
||||||
stage: format
|
stage: format
|
||||||
image: nixos/nix:latest
|
image: nixos/nix:latest
|
||||||
script:
|
script:
|
||||||
- nix-shell --run "make fmt && git diff --quiet"
|
- nix-shell --run "make fmt && git diff --quiet"
|
||||||
|
- nix-shell --run "make lint"
|
||||||
|
|
||||||
check-gomod:
|
check-gomod:
|
||||||
stage: format
|
stage: format
|
||||||
|
|
3
Makefile
3
Makefile
|
@ -11,6 +11,9 @@ test-watch:
|
||||||
fmt:
|
fmt:
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go run ./cmd/birthdaybot -config example-config.yml
|
go run ./cmd/birthdaybot -config example-config.yml
|
||||||
|
|
||||||
|
|
11
README.md
11
README.md
|
@ -24,6 +24,11 @@ Template Format, and has the following properties available:
|
||||||
- `.MonthOfBirth`: the month that the person was born, as number.
|
- `.MonthOfBirth`: the month that the person was born, as number.
|
||||||
- `.DayOfBirth`: the day that the person was born, as number.
|
- `.DayOfBirth`: the day that the person was born, as number.
|
||||||
|
|
||||||
|
There are as well some functions available to be used:
|
||||||
|
|
||||||
|
- `getYearsOld`: receives the year that a user was born, and returns
|
||||||
|
how old the user is getting.
|
||||||
|
|
||||||
### Pictures
|
### Pictures
|
||||||
|
|
||||||
Alongside the notification for each birthday, the bot can send a
|
Alongside the notification for each birthday, the bot can send a
|
||||||
|
@ -65,6 +70,12 @@ $ make run
|
||||||
- [X] Reduce logger verbosity (through levels)
|
- [X] Reduce logger verbosity (through levels)
|
||||||
- [X] Add pictures to birthday notifications
|
- [X] Add pictures to birthday notifications
|
||||||
- [X] Create a configurable template to fill with each notification
|
- [X] Create a configurable template to fill with each notification
|
||||||
|
- [X] Add some endpoints
|
||||||
|
- [X] Health endpoint
|
||||||
|
- [X] Next birthdays endpoint
|
||||||
|
- [ ] Birthday list endpoint
|
||||||
|
- [X] Allow to use a random port in web tests
|
||||||
|
- [X] Web server should be optional
|
||||||
- [ ] Create different message systems to use with the bot
|
- [ ] Create different message systems to use with the bot
|
||||||
- [X] Telegram
|
- [X] Telegram
|
||||||
- [ ] Email
|
- [ ] Email
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
John Doe,john@doe.com,12345,17/04/2192
|
John Doe,john@doe.com,12345,17/04/2192
|
||||||
|
John Doe The Second,john@doesecond.com,12543,17/04/2192
|
||||||
Jane Doe,jane@doe.com,54321,10/11/2020
|
Jane Doe,jane@doe.com,54321,10/11/2020
|
|
91
client/client.go
Normal file
91
client/client.go
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.ctrlz.es/mgdelacroix/birthdaybot/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrEmptyURL = errors.New("URL cannot be empty")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
url string
|
||||||
|
httpClient *http.Client
|
||||||
|
headers map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(opts ...Option) (*Client, error) {
|
||||||
|
client := &Client{
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
client = opt(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.url == "" {
|
||||||
|
return nil, ErrEmptyURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Do(ctx context.Context, method, path, data string, headers map[string]string) (*http.Response, error) {
|
||||||
|
url, err := url.JoinPath(c.url, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot build request url: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, url, strings.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range c.headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot do request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Health(ctx context.Context) (bool, error) {
|
||||||
|
resp, err := c.Do(ctx, http.MethodGet, "/health", "", nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp.StatusCode == 200, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) NextBirthdays(ctx context.Context) ([]*model.Birthday, error) {
|
||||||
|
resp, err := c.Do(ctx, http.MethodGet, "/next_birthdays", "", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var birthdays []*model.Birthday
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&birthdays); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot decode birthdays: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return birthdays, nil
|
||||||
|
}
|
17
client/options.go
Normal file
17
client/options.go
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
type Option func(*Client) *Client
|
||||||
|
|
||||||
|
func WithURL(url string) Option {
|
||||||
|
return func(client *Client) *Client {
|
||||||
|
client.url = url
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithHeaders(headers map[string]string) Option {
|
||||||
|
return func(client *Client) *Client {
|
||||||
|
client.headers = headers
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
}
|
|
@ -47,8 +47,16 @@ func main() {
|
||||||
c := make(chan os.Signal, 1)
|
c := make(chan os.Signal, 1)
|
||||||
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
srv.Start()
|
if err := srv.Start(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "ERROR: cannot start server: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
s := <-c
|
s := <-c
|
||||||
srv.Logger.Info("received signal, stopping", "signal", s)
|
srv.Logger.Info("received signal, stopping", "signal", s)
|
||||||
srv.Stop()
|
|
||||||
|
if err := srv.Stop(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "ERROR: cannot stop server: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
---
|
---
|
||||||
|
web:
|
||||||
|
enabled: true
|
||||||
|
port: 8080
|
||||||
|
|
||||||
birthdays:
|
birthdays:
|
||||||
file: birthdays.csv
|
file: birthdays.csv
|
||||||
template: ./birthday_message.tmpl
|
template: ./birthday_message.tmpl
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Birthday struct {
|
type Birthday struct {
|
||||||
|
@ -19,15 +20,8 @@ func (b *Birthday) Filename() string {
|
||||||
return fmt.Sprintf("%d_%d_%d_%s.png", b.YearOfBirth, b.MonthOfBirth, b.DayOfBirth, b.Phone)
|
return fmt.Sprintf("%d_%d_%d_%s.png", b.YearOfBirth, b.MonthOfBirth, b.DayOfBirth, b.Phone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Birthday) ToMap() map[string]any {
|
func (b *Birthday) Time() time.Time {
|
||||||
return map[string]any{
|
return time.Date(b.YearOfBirth, time.Month(b.MonthOfBirth), b.DayOfBirth, 0, 0, 0, 0, time.Now().Location())
|
||||||
"Name": b.Name,
|
|
||||||
"Email": b.Email,
|
|
||||||
"Phone": b.Phone,
|
|
||||||
"YearOfBirth": b.YearOfBirth,
|
|
||||||
"MonthOfBirth": b.MonthOfBirth,
|
|
||||||
"DayOfBirth": b.DayOfBirth,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBirthdayFromRecord(record []string) (*Birthday, error) {
|
func NewBirthdayFromRecord(record []string) (*Birthday, error) {
|
||||||
|
@ -108,3 +102,36 @@ func FilterByDate(birthdays []*Birthday, day, month, year int) []*Birthday {
|
||||||
}
|
}
|
||||||
return filteredBirthdays
|
return filteredBirthdays
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NextBirthdayDate(birthdays []*Birthday, now time.Time) (int, int, int) {
|
||||||
|
nowRounded := now.Round(24 * time.Hour)
|
||||||
|
|
||||||
|
var nextBirthday *Birthday
|
||||||
|
for _, birthday := range birthdays {
|
||||||
|
if nextBirthday == nil {
|
||||||
|
nextBirthday = birthday
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
birthdayTime := birthday.Time()
|
||||||
|
nextBirthdayTime := nextBirthday.Time()
|
||||||
|
|
||||||
|
if nextBirthdayTime.Before(nowRounded) && birthdayTime.After(nowRounded) {
|
||||||
|
nextBirthday = birthday
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if birthdayTime.Before(nextBirthdayTime) {
|
||||||
|
if birthdayTime.After(nowRounded) || nextBirthdayTime.Before(nowRounded) {
|
||||||
|
nextBirthday = birthday
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextBirthday.DayOfBirth, nextBirthday.MonthOfBirth, nextBirthday.YearOfBirth
|
||||||
|
}
|
||||||
|
|
||||||
|
func NextBirthdays(birthdays []*Birthday, now time.Time) []*Birthday {
|
||||||
|
day, month, year := NextBirthdayDate(birthdays, now)
|
||||||
|
return FilterByDate(birthdays, day, month, year)
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
@ -55,3 +56,68 @@ func TestFilename(t *testing.T) {
|
||||||
|
|
||||||
require.Equal(t, "2022_4_6_123456789.png", birthday.Filename())
|
require.Equal(t, "2022_4_6_123456789.png", birthday.Filename())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNextBirthdayDate(t *testing.T) {
|
||||||
|
firstBirthday := &Birthday{
|
||||||
|
YearOfBirth: 1900,
|
||||||
|
MonthOfBirth: 2,
|
||||||
|
DayOfBirth: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
secondBirthday := &Birthday{
|
||||||
|
YearOfBirth: 1900,
|
||||||
|
MonthOfBirth: 8,
|
||||||
|
DayOfBirth: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
birthdays := []*Birthday{firstBirthday, secondBirthday}
|
||||||
|
birthdaysReversed := []*Birthday{secondBirthday, firstBirthday}
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
Name string
|
||||||
|
Now time.Time
|
||||||
|
Birthdays []*Birthday
|
||||||
|
ExpectedDay int
|
||||||
|
ExpectedMonth int
|
||||||
|
ExpectedYear int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "should find first birthday",
|
||||||
|
Now: time.Date(1900, time.Month(1), 1, 0, 0, 0, 0, time.Now().Location()),
|
||||||
|
ExpectedDay: 1,
|
||||||
|
ExpectedMonth: 2,
|
||||||
|
ExpectedYear: 1900,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "should find second birthday",
|
||||||
|
Now: time.Date(1900, time.Month(4), 1, 0, 0, 0, 0, time.Now().Location()),
|
||||||
|
ExpectedDay: 1,
|
||||||
|
ExpectedMonth: 8,
|
||||||
|
ExpectedYear: 1900,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "should find first birthday for next year",
|
||||||
|
Now: time.Date(1900, time.Month(10), 1, 0, 0, 0, 0, time.Now().Location()),
|
||||||
|
ExpectedDay: 1,
|
||||||
|
ExpectedMonth: 2,
|
||||||
|
ExpectedYear: 1900,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.Name, func(t *testing.T) {
|
||||||
|
t.Run("with birthdays sorted", func(t *testing.T) {
|
||||||
|
day, month, year := NextBirthdayDate(birthdays, tc.Now)
|
||||||
|
require.Equal(t, tc.ExpectedDay, day)
|
||||||
|
require.Equal(t, tc.ExpectedMonth, month)
|
||||||
|
require.Equal(t, tc.ExpectedYear, year)
|
||||||
|
})
|
||||||
|
t.Run("with birthdays reversed", func(t *testing.T) {
|
||||||
|
day, month, year := NextBirthdayDate(birthdaysReversed, tc.Now)
|
||||||
|
require.Equal(t, tc.ExpectedDay, day)
|
||||||
|
require.Equal(t, tc.ExpectedMonth, month)
|
||||||
|
require.Equal(t, tc.ExpectedYear, year)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ package model
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
@ -19,6 +19,7 @@ var (
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Birthdays *BirthdaysConfig `yaml:"birthdays"`
|
Birthdays *BirthdaysConfig `yaml:"birthdays"`
|
||||||
Logger *LoggerConfig `yaml:"logger"`
|
Logger *LoggerConfig `yaml:"logger"`
|
||||||
|
Web *WebConfig `yaml:"web"`
|
||||||
TelegramNotifications *TelegramNotificationsConfig `yaml:"telegram_notifications"`
|
TelegramNotifications *TelegramNotificationsConfig `yaml:"telegram_notifications"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,6 +32,10 @@ func (c *Config) IsValid() error {
|
||||||
return fmt.Errorf("invalid logger config: %w", err)
|
return fmt.Errorf("invalid logger config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := c.Web.IsValid(); err != nil {
|
||||||
|
return fmt.Errorf("invalid web config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if c.TelegramNotifications != nil {
|
if c.TelegramNotifications != nil {
|
||||||
if err := c.TelegramNotifications.IsValid(); err != nil {
|
if err := c.TelegramNotifications.IsValid(); err != nil {
|
||||||
return fmt.Errorf("invalid telegram notifications config: %w", err)
|
return fmt.Errorf("invalid telegram notifications config: %w", err)
|
||||||
|
@ -53,6 +58,12 @@ func (c *Config) SetDefaults() {
|
||||||
|
|
||||||
c.Logger.SetDefaults()
|
c.Logger.SetDefaults()
|
||||||
|
|
||||||
|
if c.Web == nil {
|
||||||
|
c.Web = &WebConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Web.SetDefaults()
|
||||||
|
|
||||||
if c.TelegramNotifications != nil {
|
if c.TelegramNotifications != nil {
|
||||||
c.TelegramNotifications.SetDefaults()
|
c.TelegramNotifications.SetDefaults()
|
||||||
}
|
}
|
||||||
|
@ -99,6 +110,17 @@ func (lc *LoggerConfig) IsValid() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WebConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *WebConfig) SetDefaults() {}
|
||||||
|
|
||||||
|
func (wc *WebConfig) IsValid() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type TelegramNotificationsConfig struct {
|
type TelegramNotificationsConfig struct {
|
||||||
BotToken string `yaml:"bot_token"`
|
BotToken string `yaml:"bot_token"`
|
||||||
ChannelID string `yaml:"channel_id"`
|
ChannelID string `yaml:"channel_id"`
|
||||||
|
@ -119,7 +141,7 @@ func (tnc *TelegramNotificationsConfig) IsValid() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReadConfig(path string) (*Config, error) {
|
func ReadConfig(path string) (*Config, error) {
|
||||||
fileBytes, err := ioutil.ReadFile(path)
|
fileBytes, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@ package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
@ -11,7 +10,7 @@ import (
|
||||||
|
|
||||||
func TestReadConfig(t *testing.T) {
|
func TestReadConfig(t *testing.T) {
|
||||||
t.Run("should correctly read a configuration file", func(t *testing.T) {
|
t.Run("should correctly read a configuration file", func(t *testing.T) {
|
||||||
f, err := ioutil.TempFile("", "birthdaybot-")
|
f, err := os.CreateTemp("", "birthdaybot-")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.Remove(f.Name())
|
defer os.Remove(f.Name())
|
||||||
|
|
||||||
|
@ -25,7 +24,7 @@ func TestReadConfig(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should fail if the file doesn't exist", func(t *testing.T) {
|
t.Run("should fail if the file doesn't exist", func(t *testing.T) {
|
||||||
f, err := ioutil.TempFile("", "birthdaybot-")
|
f, err := os.CreateTemp("", "birthdaybot-")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
f.Close()
|
f.Close()
|
||||||
os.Remove(f.Name())
|
os.Remove(f.Name())
|
||||||
|
|
|
@ -43,7 +43,7 @@ func (tns *TelegramNotificationService) Notify(birthday *model.Birthday, templat
|
||||||
var msgText string
|
var msgText string
|
||||||
if template != nil {
|
if template != nil {
|
||||||
var stringBuffer bytes.Buffer
|
var stringBuffer bytes.Buffer
|
||||||
if err := template.Execute(&stringBuffer, birthday.ToMap()); err != nil {
|
if err := template.Execute(&stringBuffer, birthday); err != nil {
|
||||||
return fmt.Errorf("cannot execute template for birthday: %w", err)
|
return fmt.Errorf("cannot execute template for birthday: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ package parser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
@ -10,7 +10,7 @@ import (
|
||||||
|
|
||||||
func TestParseCsv(t *testing.T) {
|
func TestParseCsv(t *testing.T) {
|
||||||
t.Run("should correctly parse a valid CSV file", func(t *testing.T) {
|
t.Run("should correctly parse a valid CSV file", func(t *testing.T) {
|
||||||
f, err := ioutil.TempFile("", "birthdaybot-")
|
f, err := os.CreateTemp("", "birthdaybot-")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, werr := io.WriteString(f, "John Doe , john@doe.com, 1234, 17/04/2192\nJane Doe,jane@doe.com,4321,15/01/2020\n")
|
_, werr := io.WriteString(f, "John Doe , john@doe.com, 1234, 17/04/2192\nJane Doe,jane@doe.com,4321,15/01/2020\n")
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
¡Mañana es el cumpleaños de {{.Name}}! Puedes felicitarle o bien escribiendo a {{.Email}} o bien llamando al número {{.Phone}}
|
¡Mañana es el cumpleaños de {{.Name}}! Cumple {{getYearsOld .YearOfBirth}} años, puedes felicitarle o bien escribiendo a {{.Email}} o bien llamando al número {{.Phone}}
|
|
@ -1,10 +1,11 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.ctrlz.es/mgdelacroix/birthdaybot/client"
|
||||||
"git.ctrlz.es/mgdelacroix/birthdaybot/model"
|
"git.ctrlz.es/mgdelacroix/birthdaybot/model"
|
||||||
"git.ctrlz.es/mgdelacroix/birthdaybot/notification"
|
"git.ctrlz.es/mgdelacroix/birthdaybot/notification"
|
||||||
notification_mocks "git.ctrlz.es/mgdelacroix/birthdaybot/notification/mocks"
|
notification_mocks "git.ctrlz.es/mgdelacroix/birthdaybot/notification/mocks"
|
||||||
|
@ -20,18 +21,22 @@ type TestHelper struct {
|
||||||
mockNotificationService *notification_mocks.MockNotificationService
|
mockNotificationService *notification_mocks.MockNotificationService
|
||||||
mockWorker *server_mocks.MockWorker
|
mockWorker *server_mocks.MockWorker
|
||||||
srv *Server
|
srv *Server
|
||||||
|
client *client.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func testConfig(t *testing.T) *model.Config {
|
func testConfig(t *testing.T) *model.Config {
|
||||||
f, err := ioutil.TempFile("", "birthdaybot-")
|
f, err := os.CreateTemp("", "birthdaybot-")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, f.Close())
|
require.NoError(t, f.Close())
|
||||||
require.NoError(t, os.Remove(f.Name()))
|
require.NoError(t, os.Remove(f.Name()))
|
||||||
|
|
||||||
return &model.Config{Birthdays: &model.BirthdaysConfig{File: f.Name()}}
|
return &model.Config{
|
||||||
|
Web: &model.WebConfig{Enabled: true, Port: 0},
|
||||||
|
Birthdays: &model.BirthdaysConfig{File: f.Name()},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetupTestHelper(t *testing.T) *TestHelper {
|
func SetupTestHelper(t *testing.T, opts ...Option) *TestHelper {
|
||||||
th := &TestHelper{t: t}
|
th := &TestHelper{t: t}
|
||||||
th.ctrl = gomock.NewController(t)
|
th.ctrl = gomock.NewController(t)
|
||||||
|
|
||||||
|
@ -54,22 +59,28 @@ func SetupTestHelper(t *testing.T) *TestHelper {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
serverOpts := append([]Option{
|
||||||
th.srv, err = New(
|
|
||||||
WithConfig(testConfig(t)),
|
WithConfig(testConfig(t)),
|
||||||
WithLogger(log.New(os.Stderr)),
|
WithLogger(log.New(os.Stderr)),
|
||||||
WithBirthdays(birthdays),
|
WithBirthdays(birthdays),
|
||||||
WithNotificationServices(notificationServices),
|
WithNotificationServices(notificationServices),
|
||||||
WithWorkers(workers),
|
WithWorkers(workers),
|
||||||
)
|
}, opts...)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
th.srv, err = New(serverOpts...)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
th.srv.Start()
|
require.NoError(t, th.srv.Start())
|
||||||
|
|
||||||
|
client, err := client.New(client.WithURL(fmt.Sprintf("http://127.0.0.1:%d", th.srv.WebServer.Port())))
|
||||||
|
require.NoError(t, err)
|
||||||
|
th.client = client
|
||||||
|
|
||||||
return th
|
return th
|
||||||
}
|
}
|
||||||
|
|
||||||
func (th *TestHelper) TearDown() {
|
func (th *TestHelper) TearDown() {
|
||||||
th.srv.Stop()
|
require.NoError(th.t, th.srv.Stop())
|
||||||
th.ctrl.Finish()
|
th.ctrl.Finish()
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,9 @@ package server
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.ctrlz.es/mgdelacroix/birthdaybot/model"
|
"git.ctrlz.es/mgdelacroix/birthdaybot/model"
|
||||||
"git.ctrlz.es/mgdelacroix/birthdaybot/notification"
|
"git.ctrlz.es/mgdelacroix/birthdaybot/notification"
|
||||||
|
@ -20,6 +22,7 @@ var (
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Logger *log.Logger
|
Logger *log.Logger
|
||||||
Config *model.Config
|
Config *model.Config
|
||||||
|
WebServer *WebServer
|
||||||
workers []Worker
|
workers []Worker
|
||||||
birthdays []*model.Birthday
|
birthdays []*model.Birthday
|
||||||
notificationServices []notification.NotificationService
|
notificationServices []notification.NotificationService
|
||||||
|
@ -89,30 +92,70 @@ func New(options ...Option) (*Server, error) {
|
||||||
if srv.Config.Birthdays.Template != "" {
|
if srv.Config.Birthdays.Template != "" {
|
||||||
srv.Logger.Debug("parsing birthday template", "file", srv.Config.Birthdays.Template)
|
srv.Logger.Debug("parsing birthday template", "file", srv.Config.Birthdays.Template)
|
||||||
|
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"getYearsOld": func(yearOfBirth int) int {
|
||||||
|
return time.Now().Year() - yearOfBirth
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
srv.tmpl, err = template.ParseFiles(srv.Config.Birthdays.Template)
|
srv.tmpl, err = template.
|
||||||
|
New(path.Base(srv.Config.Birthdays.Template)).
|
||||||
|
Funcs(funcs).
|
||||||
|
ParseFiles(srv.Config.Birthdays.Template)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot parse template file %q: %w", srv.Config.Birthdays.Template, err)
|
return nil, fmt.Errorf("cannot parse template file %q: %w", srv.Config.Birthdays.Template, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if srv.WebServer == nil && srv.Config.Web.Enabled {
|
||||||
|
srv.Logger.Debug("creating web server")
|
||||||
|
|
||||||
|
ws, err := NewWebServer(srv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot create web server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.WebServer = ws
|
||||||
|
}
|
||||||
|
|
||||||
return srv, nil
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Start() {
|
func (s *Server) Start() error {
|
||||||
s.Logger.Info("starting server")
|
s.Logger.Info("starting server")
|
||||||
|
|
||||||
|
if s.WebServer != nil {
|
||||||
|
if err := s.WebServer.Start(); err != nil {
|
||||||
|
return fmt.Errorf("cannot start web server: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, worker := range s.workers {
|
for _, worker := range s.workers {
|
||||||
worker.Start()
|
worker.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
s.Logger.Debug("server started", "workers", len(s.workers))
|
s.Logger.Debug("server started", "workers", len(s.workers))
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Stop() {
|
func (s *Server) Stop() error {
|
||||||
s.Logger.Info("stopping server")
|
s.Logger.Info("stopping server")
|
||||||
|
|
||||||
|
if s.WebServer != nil {
|
||||||
|
if err := s.WebServer.Stop(); err != nil {
|
||||||
|
return fmt.Errorf("cannot stop web server: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, worker := range s.workers {
|
for _, worker := range s.workers {
|
||||||
worker.Stop()
|
worker.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
s.Logger.Debug("server stopped", "workers", len(s.workers))
|
s.Logger.Debug("server stopped", "workers", len(s.workers))
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Notify(birthday *model.Birthday) error {
|
func (s *Server) Notify(birthday *model.Birthday) error {
|
||||||
|
@ -133,3 +176,7 @@ func (s *Server) Notify(birthday *model.Birthday) error {
|
||||||
func (s *Server) Birthdays() []*model.Birthday {
|
func (s *Server) Birthdays() []*model.Birthday {
|
||||||
return s.birthdays
|
return s.birthdays
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) NextBirthdays() []*model.Birthday {
|
||||||
|
return model.NextBirthdays(s.birthdays, time.Now())
|
||||||
|
}
|
||||||
|
|
|
@ -1,15 +1,21 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ctrlz.es/mgdelacroix/birthdaybot/model"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNotify(t *testing.T) {
|
func TestNotify(t *testing.T) {
|
||||||
th := SetupTestHelper(t)
|
th := SetupTestHelper(t)
|
||||||
defer th.TearDown()
|
defer th.TearDown()
|
||||||
|
|
||||||
t.Run("should correctly use the notification services to notify", func(t *testing.T) {
|
t.Run("should correctly use the notification services to notify", func(t *testing.T) {
|
||||||
birthday := th.srv.birthdays[0]
|
birthday := th.srv.birthdays[0]
|
||||||
th.mockNotificationService.
|
th.mockNotificationService.
|
||||||
|
@ -35,3 +41,53 @@ func TestNotify(t *testing.T) {
|
||||||
require.ErrorIs(t, err, mockErr)
|
require.ErrorIs(t, err, mockErr)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTemplate(t *testing.T) {
|
||||||
|
t.Run("template should work with birthday data", func(t *testing.T) {
|
||||||
|
// create a template file and populate it
|
||||||
|
f, err := os.CreateTemp("", "birthdaybot-config-")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, werr := fmt.Fprint(f, "My name is {{.Name}}")
|
||||||
|
require.NoError(t, werr)
|
||||||
|
require.NoError(t, f.Close())
|
||||||
|
|
||||||
|
// create a test config and set the template
|
||||||
|
config := testConfig(t)
|
||||||
|
config.Birthdays.Template = f.Name()
|
||||||
|
|
||||||
|
// create the test helper with the custom config
|
||||||
|
th := SetupTestHelper(t, WithConfig(config))
|
||||||
|
defer th.TearDown()
|
||||||
|
|
||||||
|
birthday := &model.Birthday{Name: "Jane Doe"}
|
||||||
|
expectedString := "My name is Jane Doe"
|
||||||
|
|
||||||
|
var stringBuffer bytes.Buffer
|
||||||
|
require.NoError(t, th.srv.tmpl.Execute(&stringBuffer, birthday))
|
||||||
|
require.Equal(t, expectedString, stringBuffer.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("template should work with custom functions", func(t *testing.T) {
|
||||||
|
// create a template file and populate it
|
||||||
|
f, err := os.CreateTemp("", "birthdaybot-config-")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, werr := fmt.Fprint(f, "I'm getting {{getYearsOld .YearOfBirth}} years old")
|
||||||
|
require.NoError(t, werr)
|
||||||
|
require.NoError(t, f.Close())
|
||||||
|
|
||||||
|
// create a test config and set the template
|
||||||
|
config := testConfig(t)
|
||||||
|
config.Birthdays.Template = f.Name()
|
||||||
|
|
||||||
|
// create the test helper with the custom config
|
||||||
|
th := SetupTestHelper(t, WithConfig(config))
|
||||||
|
defer th.TearDown()
|
||||||
|
|
||||||
|
birthday := &model.Birthday{YearOfBirth: 1980}
|
||||||
|
expectedString := fmt.Sprintf("I'm getting %d years old", time.Now().Year()-birthday.YearOfBirth)
|
||||||
|
|
||||||
|
var stringBuffer bytes.Buffer
|
||||||
|
require.NoError(t, th.srv.tmpl.Execute(&stringBuffer, birthday))
|
||||||
|
require.Equal(t, expectedString, stringBuffer.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
88
server/web.go
Normal file
88
server/web.go
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebServer struct {
|
||||||
|
server *Server
|
||||||
|
listener net.Listener
|
||||||
|
logger *log.Logger
|
||||||
|
httpServer *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebServer(server *Server) (*WebServer, error) {
|
||||||
|
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", server.Config.Web.Port))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot create listener: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws := &WebServer{
|
||||||
|
server: server,
|
||||||
|
listener: listener,
|
||||||
|
logger: server.Logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/health", ws.healthHandler)
|
||||||
|
mux.HandleFunc("/next_birthdays", ws.nextBirthdayHandler)
|
||||||
|
|
||||||
|
ws.httpServer = &http.Server{Handler: mux}
|
||||||
|
|
||||||
|
return ws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) Start() error {
|
||||||
|
ws.logger.Debug("starting web server")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := ws.httpServer.Serve(ws.listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
ws.logger.Fatal("cannot start web server", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) Stop() error {
|
||||||
|
ws.logger.Debug("stopping web server")
|
||||||
|
|
||||||
|
if err := ws.httpServer.Close(); err != nil {
|
||||||
|
return fmt.Errorf("cannot stop web server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) Port() int {
|
||||||
|
return ws.listener.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, "OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) nextBirthdayHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ws.JSON(w, http.StatusOK, ws.server.NextBirthdays())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) JSON(w http.ResponseWriter, statusCode int, data any) {
|
||||||
|
b, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
ws.logger.Error("cannot marshal data", "error", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
if _, err := w.Write(b); err != nil {
|
||||||
|
ws.logger.Error("cannot write to response writer", "error", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
39
server/web_test.go
Normal file
39
server/web_test.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPort(t *testing.T) {
|
||||||
|
th := SetupTestHelper(t)
|
||||||
|
defer th.TearDown()
|
||||||
|
|
||||||
|
port := th.srv.WebServer.Port()
|
||||||
|
require.NotEmpty(t, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthHandler(t *testing.T) {
|
||||||
|
th := SetupTestHelper(t)
|
||||||
|
defer th.TearDown()
|
||||||
|
|
||||||
|
t.Run("should return ok if the server is up and running", func(t *testing.T) {
|
||||||
|
health, err := th.client.Health(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, health)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextBirthdaysHandler(t *testing.T) {
|
||||||
|
th := SetupTestHelper(t)
|
||||||
|
defer th.TearDown()
|
||||||
|
|
||||||
|
t.Run("should return a list if the server is up and running", func(t *testing.T) {
|
||||||
|
birthdays, err := th.client.NextBirthdays(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, birthdays, 1)
|
||||||
|
require.Equal(t, "john@doe.com", birthdays[0].Email)
|
||||||
|
})
|
||||||
|
}
|
|
@ -48,7 +48,9 @@ func (w *SimpleWorker) notifyDay(year, month, day int) {
|
||||||
|
|
||||||
for _, b := range birthdays {
|
for _, b := range birthdays {
|
||||||
w.logger.Info("notifying for birthday", "name", b.Name)
|
w.logger.Info("notifying for birthday", "name", b.Name)
|
||||||
w.server.Notify(b)
|
if err := w.server.Notify(b); err != nil {
|
||||||
|
w.logger.Error("error notifying for birthday", "name", b.Name, "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ pkgs.mkShell {
|
||||||
gnumake
|
gnumake
|
||||||
modd
|
modd
|
||||||
mockgen
|
mockgen
|
||||||
|
golangci-lint
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
|
Loading…
Reference in a new issue