1
0
Fork 0

Big change

- Reorganised request template handling and error handling
- Added Content-Type response
- Implemented templating data
- Break out router
main
Ambrose Chua 2020-11-10 17:17:11 +08:00
parent b18b3c0035
commit 5e9d1ab6cb
20 changed files with 425 additions and 100 deletions

View File

@ -16,7 +16,7 @@ clean:
.PHONY: build
build: datetime
datetime: *.go
datetime: *.go data/*.go
$(GO) build -tags "$(TAGS)" -v -o datetime
.PHONY: test

55
app.go
View File

@ -1,6 +1,7 @@
package main
import (
"errors"
"html/template"
"net/http"
@ -8,6 +9,9 @@ import (
"go.uber.org/zap"
)
// ErrNoTemplate is returned when a template was not found on the server
var ErrNoTemplate = errors.New("missing template")
// Datetime is the main application server
type Datetime struct {
*http.ServeMux
@ -19,7 +23,7 @@ type Datetime struct {
// like templates and data exist
func NewDatetime() (*Datetime, error) {
// Data
tmpl, err := template.ParseGlob("templates/*")
tmpl, err := template.New("templates").Funcs(templateFuncs).ParseGlob("templates/*")
if err != nil {
return nil, err
}
@ -42,6 +46,11 @@ func NewDatetime() (*Datetime, error) {
return app, nil
}
type appRequest struct {
App Datetime
Req Request
}
// index handles all incoming page requests
func (app Datetime) index(w http.ResponseWriter, req *http.Request) {
var err error
@ -51,45 +60,29 @@ func (app Datetime) index(w http.ResponseWriter, req *http.Request) {
return
}
accept := req.Header.Get("Accept")
tmpl, acceptable := app.chooseTemplate(accept)
if !acceptable {
w.WriteHeader(http.StatusNotAcceptable)
return
}
tmpl := app.loadTemplate("index", w, req)
if tmpl == nil {
l.Error("unable to find template", zap.String("accept", accept))
w.WriteHeader(http.StatusInternalServerError)
return
}
if req.Method == http.MethodHead {
return
}
l.Debug("", zap.Reflect("url", req.URL))
err = tmpl.Execute(w, nil)
request := Request{}
if req.URL.Path != "/" {
request, err = ParseRequest(req.URL)
if err != nil {
l.Debug("parse failed", zap.Error(err))
app.error(HTTPError{http.StatusBadRequest, err}, w, req)
return
}
}
l.Debug("rendering template", zap.Reflect("request", request))
err = tmpl.Execute(w, appRequest{app, request})
if err != nil {
l.Error("templating failed", zap.Error(err))
w.WriteHeader(http.StatusInternalServerError) // Usually this will fail
app.templateError(HTTPError{http.StatusInternalServerError, err}, w, req)
return
}
}
// chooseTemplate returns a template based on the accepted mime types from the
// client, and if a template cannot be found it returns a nil template.
func (app Datetime) chooseTemplate(accept string) (t *template.Template, acceptable bool) {
responseType := chooseResponseType(accept)
templateName := ""
switch responseType {
case responsePlain:
templateName = "index.txt"
case responseHTML:
templateName = "index.html"
case responseAny:
templateName = "index.txt"
case responseUnknown:
return nil, false
}
return app.tmpl.Lookup(templateName), true
}

View File

@ -7,27 +7,31 @@ import (
)
func TestChooseTemplate(t *testing.T) {
tmpl, err := template.ParseGlob("templates/*")
tmpl, err := template.New("templates").Funcs(templateFuncs).ParseGlob("templates/*")
if err != nil {
panic(err)
}
app := &Datetime{tmpl: tmpl}
type chooseTest struct {
accept string
acceptable bool
template string
accept string
acceptable bool
contentType string
template string
}
tests := []chooseTest{
{"text/html", true, "index.html"},
{"text/html;q=0.9,text/plain", true, "index.txt"},
{"image/png", false, ""},
{"*/*", true, "index.txt"},
{"text/html", true, "text/html", "index.html"},
{"text/html;q=0.9,text/plain", true, "text/plain", "index.txt"},
{"image/png", false, "", ""},
{"*/*", true, "text/plain", "index.txt"},
}
for _, test := range tests {
tmpl, acceptable := app.chooseTemplate(test.accept)
tmpl, contentType, acceptable := app.chooseTemplate(test.accept, "index")
fn := fmt.Sprintf("chooseTemplate(\"%s\")", test.accept)
if contentType != test.contentType {
t.Errorf("%s; contentType = %v; wanted %v", fn, contentType, test.contentType)
}
if acceptable != test.acceptable {
t.Errorf("%s; acceptable = %v; wanted %v", fn, acceptable, test.acceptable)
}

View File

@ -3,6 +3,7 @@ package data
import (
"encoding/json"
"io/ioutil"
"strings"
)
// ReadCities opens the file "data/cities.json" and reads it into a map
@ -20,3 +21,15 @@ func ReadCities() (map[string]*City, error) {
return cities, nil
}
func extendName(names ...string) string {
return strings.Join(names, ", ")
}
// FullName returns a fully qualified human readable name
func (c City) FullName() string {
if len(c.Admin1.Name) > 0 {
return extendName(c.Name, c.Admin1.Name, c.Country.Name)
}
return extendName(c.Name, c.Country.Name)
}

View File

@ -1,6 +1,6 @@
package data
// city represents a city that belongs inside an administrative division level 1
// City represents a city that belongs inside an administrative division level 1
// and a country
type City struct {
// Ref is the ASCII name of the city

60
error.go Normal file
View File

@ -0,0 +1,60 @@
package main
import (
"net/http"
"go.uber.org/zap"
)
// HTTPError is an error that can be rendered into a HTML page to follow HTTP
// status semantics
type HTTPError struct {
Status int
Error error
}
func (app Datetime) error(httpErr HTTPError, w http.ResponseWriter, req *http.Request) {
tmpl := app.loadTemplate("error", w, req)
if tmpl == nil {
return
}
w.WriteHeader(httpErr.Status)
if req.Method == http.MethodHead {
return
}
err := tmpl.Execute(w, httpErr)
if err != nil {
l.Error("template failed", zap.Error(err))
app.templateError(HTTPError{http.StatusInternalServerError, err}, w, req)
return
}
}
func (app Datetime) simpleError(httpErr HTTPError, w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(httpErr.Status)
if req.Method == http.MethodHead {
return
}
if httpErr.Error != nil {
w.Write([]byte(http.StatusText(httpErr.Status) + ": " + httpErr.Error.Error()))
} else {
w.Write([]byte(http.StatusText(httpErr.Status)))
}
}
func (app Datetime) templateError(httpErr HTTPError, w http.ResponseWriter, req *http.Request) {
// Sadly, we probably already sent out the header
//w.Header().Set("Content-Type", "text/plain")
//w.WriteHeader(httpErr.Status)
if httpErr.Error != nil {
w.Write([]byte("\n" + http.StatusText(httpErr.Status) + ": " + httpErr.Error.Error()))
} else {
w.Write([]byte("\n" + http.StatusText(httpErr.Status)))
}
}

View File

@ -4,8 +4,11 @@ package main
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func zapConfig() zap.Config {
return zap.NewDevelopmentConfig()
dev := zap.NewDevelopmentConfig()
dev.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
return dev
}

View File

@ -27,7 +27,7 @@ import (
"github.com/serverwentdown/datetime.link/data"
)
var regexName = regexp.MustCompile(`[^a-zA-Z1-9]+`)
var regexName = regexp.MustCompile(`[^a-zA-Z1-9']+`)
func extendRef(refs ...string) string {
return strings.Join(refs, "-")

81
template.go Normal file
View File

@ -0,0 +1,81 @@
package main
import (
"html/template"
"net/http"
"go.uber.org/zap"
)
var templateFuncs = map[string]interface{}{
"statusText": templateFuncStatusText,
"thisIsSafe": templateFuncThisIsSafe,
// Formatting
"formatOffset": templateFuncFormatOffset,
// Logic
"resolveZone": templateFuncResolveZone,
}
func templateFuncStatusText(s int) string {
return http.StatusText(s)
}
func templateFuncThisIsSafe(s string) template.HTML {
return template.HTML(s)
}
func templateFuncFormatOffset(offset int) string {
return FormatZoneOffset(offset)
}
// ResolvedZone holds a resolved zone or an error
type ResolvedZone struct {
Zone
Error error
}
func templateFuncResolveZone(app Datetime, zone string) ResolvedZone {
z, err := ResolveZone(app.cities, zone)
if err != nil {
l.Debug("unable to resolve zone", zap.Reflect("zone", zone), zap.Error(err))
}
return ResolvedZone{z, err}
}
// loadTemplate returns a matching template for the request. It also causes an
// error if the template is not found or the Accept parameters are incorrect.
func (app Datetime) loadTemplate(name string, w http.ResponseWriter, req *http.Request) *template.Template {
accept := req.Header.Get("Accept")
tmpl, contentType, acceptable := app.chooseTemplate(accept, name)
if !acceptable {
app.simpleError(HTTPError{http.StatusNotAcceptable, nil}, w, req)
return nil
}
if tmpl == nil {
l.Error("unable to find template", zap.String("name", name), zap.String("accept", accept))
app.simpleError(HTTPError{http.StatusInternalServerError, ErrNoTemplate}, w, req)
return nil
}
w.Header().Set("Content-Type", contentType)
return tmpl
}
// chooseTemplate returns a template based on the accepted mime types from the
// client, and if a template cannot be found it returns a nil template.
func (app Datetime) chooseTemplate(accept string, name string) (t *template.Template, contentType string, acceptable bool) {
acceptable = true
switch chooseResponseType(accept) {
case responsePlain:
t = app.tmpl.Lookup(name + ".txt")
contentType = "text/plain"
case responseHTML:
t = app.tmpl.Lookup(name + ".html")
contentType = "text/html"
case responseAny:
t = app.tmpl.Lookup(name + ".txt")
contentType = "text/plain"
case responseUnknown:
acceptable = false
return
}
return
}

27
template_test.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"testing"
)
func TestTemplateFuncFormatOffset(t *testing.T) {
want, got := "+06:06", templateFuncFormatOffset(6*60*60+6*60)
if want != got {
t.Fatalf("got offset %v, want offset %v", got, want)
}
want, got = "-12:15", templateFuncFormatOffset(-(12*60*60 + 15*60))
if want != got {
t.Fatalf("got offset %v, want offset %v", got, want)
}
want, got = "\u00B100:00", templateFuncFormatOffset(-(0*60*60 + 0*60))
if want != got {
t.Fatalf("got offset %v, want offset %v", got, want)
}
want, got = "+00:01", templateFuncFormatOffset(0*60*60+1*60)
if want != got {
t.Fatalf("got offset %v, want offset %v", got, want)
}
}

25
templates/error.html Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{.Status | statusText}} - datetime.link</title>
<meta name="viewport" content="width=device-width">
{{template "resources.html"}}
</head>
<body>
{{template "resources-body.html"}}
<main id="app">
<h1 title="Status Code: {{.Status}}">{{.Status | statusText}}</h1>
{{if eq .Status 400}}
<p title="Error: {{.Error.Error}}">The URL path is not valid</p>
{{else}}
<p>{{.Error.Error}}</p>
{{end}}
</main>
{{template "footer.html"}}
</body>
</html>

2
templates/error.txt Normal file
View File

@ -0,0 +1,2 @@
{{.Status}} {{.Status | statusText | thisIsSafe}}
{{.Error | thisIsSafe}}

11
templates/footer.html Normal file
View File

@ -0,0 +1,11 @@
<footer>
<ul class="list-inline">
<li><a href="https://github.com/serverwentdown/datetime.link" target="_blank">About datetime.link</a></li><!--
--><li><a href="https://github.com/serverwentdown/datetime.link/issues/new" target="_blank">Found a bug?</a></li><!--
--><li onclick="toggleTheme()"><!--
--><span class="icon theme-toggle-system">{{template "icon_solid_adjust.svg"}}</span><span class="theme-toggle-name theme-toggle-system">&nbsp;System theme</span><!--
--><span class="icon theme-toggle-dark">{{template "icon_solid_moon.svg"}}</span><span class="theme-toggle-name theme-toggle-dark">&nbsp;Dark theme</span><!--
--><span class="icon theme-toggle-light">{{template "icon_solid_sun.svg"}}</span><span class="theme-toggle-name theme-toggle-light">&nbsp;Light theme</span><!--
--></li>
</ul>
</footer>

View File

@ -4,13 +4,38 @@
<meta charset="utf-8">
<title>datetime.link</title>
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<link rel="stylesheet" href="/css/styles.css">
{{template "resources.html"}}
</head>
<body>
<script src="/js/theme.js"></script>
{{template "resources-body.html"}}
<main id="app">
{{$app := .App}}
{{$t := .Req.Time}}
{{range .Req.Zones}}
<d-zone zone="{{.}}">
{{with resolveZone $app .}}
{{if .Error}}
<d-zoneerror>
Unable to load zone. The URL path might not be valid
</d-zoneerror>
{{else}}
{{$zt := $t.In .Location}}
<d-zoneinfo>
<d-zonename>{{.Name}}</d-zonename>
{{if not .IsOffset}}
<d-zoneoffset>{{.TimeOffset $t | formatOffset}}</d-zoneoffset>
{{end}}
<d-date date="{{$zt.Format "2006-01-02"}}">{{$zt.Format "2006-01-02"}}</d-date>
</d-zoneinfo>
<d-zonefigure>
<d-time time="{{$zt.Format "15:04"}}">{{$zt.Format "15:04"}}</d-time>
</d-zonefigure>
{{end}}
{{end}}
</d-zone>
{{end}}
<!--
<d-zone>
<d-zoneinfo>
@ -45,17 +70,7 @@
-->
</main>
<footer>
<ul class="list-inline">
<li><a href="https://github.com/serverwentdown/datetime.link" target="_blank">About datetime.link</a></li><!--
--><li><a href="https://github.com/serverwentdown/datetime.link/issues/new" target="_blank">Found a bug?</a></li><!--
--><li onclick="toggleTheme()"><!--
--><span class="icon theme-toggle-system">{{template "icon_solid_adjust.svg"}}</span><span class="theme-toggle-name theme-toggle-system">&nbsp;System theme</span><!--
--><span class="icon theme-toggle-dark">{{template "icon_solid_moon.svg"}}</span><span class="theme-toggle-name theme-toggle-dark">&nbsp;Dark theme</span><!--
--><span class="icon theme-toggle-light">{{template "icon_solid_sun.svg"}}</span><span class="theme-toggle-name theme-toggle-light">&nbsp;Light theme</span><!--
--></li>
</ul>
</footer>
{{template "footer.html"}}
<!-- Work in progress...
<script src="/js/third-party/luxon.min.js"></script>

View File

@ -0,0 +1 @@
<script src="/js/theme.js"></script>

2
templates/resources.html Normal file
View File

@ -0,0 +1,2 @@
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<link rel="stylesheet" href="/css/styles.css">

10
url.go
View File

@ -2,16 +2,20 @@ package main
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
)
// ErrMissingComponent is thrown when the URL has empty or missing components
var ErrMissingComponent = errors.New("missing URL component")
var ErrMissingComponent = errors.New("missing path component")
// ErrTooManyComponent is thrown when there are more than 2 components
var ErrTooManyComponent = errors.New("too many components")
var ErrTooManyComponent = errors.New("too many path components")
// ErrInvalidTime is thrown in a time.ParseError
var ErrInvalidTime = errors.New("invalid ISO 8601 time")
var timeRFC3339NoSec = "2006-01-02T15:04Z07:00"
var timeFormats = []string{time.RFC3339, timeRFC3339NoSec}
@ -47,7 +51,7 @@ func ParseRequest(u *url.URL) (Request, error) {
}
}
if err != nil {
return Request{}, err
return Request{}, fmt.Errorf("%w: %v", ErrInvalidTime, err)
}
// Split zones

View File

@ -25,7 +25,7 @@ func TestURLParse(t *testing.T) {
return
}
want := Request{
time.Date(2020, 6, 2, 14, 0, 0, 0, time.FixedZone("UTC +8", 8*60*60)),
time.Date(2020, time.June, 2, 14, 0, 0, 0, time.FixedZone("UTC +8", 8*60*60)),
[]string{"Singapore", "Malaysia"},
}
if !cmp.Equal(got, want) {
@ -39,7 +39,7 @@ func TestURLParse(t *testing.T) {
return
}
want = Request{
time.Date(2019, 4, 30, 18, 0, 0, 0, time.FixedZone("UTC", 0)),
time.Date(2019, time.April, 30, 18, 0, 0, 0, time.FixedZone("UTC", 0)),
[]string{"Nowhere"},
}
if !cmp.Equal(got, want) {
@ -74,15 +74,13 @@ func TestURLParseFail(t *testing.T) {
u = mustURLParse("http://test/2000-01-13T00:00Z08:00/hi")
_, err = ParseRequest(u)
_, isParseError := err.(*time.ParseError)
if !isParseError {
t.Errorf("got error %v, want time.ParseError", err)
if !errors.Is(err, ErrInvalidTime) {
t.Errorf("got error %v, want error %v", err, ErrInvalidTime)
}
u = mustURLParse("http://test/2000-01-13 00:00+08:00/hi")
_, err = ParseRequest(u)
_, isParseError = err.(*time.ParseError)
if !isParseError {
t.Errorf("got error %v, want time.ParseError", err)
if !errors.Is(err, ErrInvalidTime) {
t.Errorf("got error %v, want error %v", err, ErrInvalidTime)
}
}

73
zone.go
View File

@ -2,11 +2,13 @@ package main
import (
"errors"
"fmt"
"regexp"
"strconv"
"time"
"github.com/serverwentdown/datetime.link/data"
"go.uber.org/zap"
)
// ErrZoneNotFound is thrown when a zone string has no match
@ -15,6 +17,9 @@ var ErrZoneNotFound = errors.New("zone not found")
// ErrZoneOffsetInvalid is thrown when a zone string is an invalid zone offset
var ErrZoneOffsetInvalid = errors.New("offset zone invalid")
const zoneHour = 60 * 60
const zoneMinute = 60
var zoneOffsetRegexp = regexp.MustCompile(`^[+-][0-9]{2}:[0-9]{2}$`)
// SearchCities looks up a city by it's reference
@ -27,9 +32,9 @@ func SearchCities(cities map[string]*data.City, city string) (*data.City, error)
}
// ParseZoneOffset parses a zone string into a time.Location
func ParseZoneOffset(zone string) (*time.Location, error) {
func ParseZoneOffset(zone string) (int, error) {
if !zoneOffsetRegexp.MatchString(zone) {
return nil, ErrZoneOffsetInvalid
return 0, ErrZoneOffsetInvalid
}
// Assume that if it satisfies the regex, it satisfies the length and won't
// fail to parse
@ -44,9 +49,26 @@ func ParseZoneOffset(zone string) (*time.Location, error) {
// Allow hour offsets greater that 24
m, _ := strconv.ParseUint(zone[1+3:1+3+2], 10, 64)
if m >= 60 {
return nil, ErrZoneOffsetInvalid
return 0, ErrZoneOffsetInvalid
}
return time.FixedZone("UTC"+zone, d*(int(h)*60*60+int(m)*60)), nil
offset := d * (int(h)*zoneHour + int(m)*zoneMinute)
return offset, nil
}
// FormatZoneOffset formats an offset into a string
func FormatZoneOffset(offset int) string {
neg := offset < 0
s := '+'
if neg {
s = '-'
offset = -offset
}
if offset == 0 {
return "\u00B100:00"
}
h := offset / zoneHour
m := (offset % zoneHour) / zoneMinute
return fmt.Sprintf("%c%02d:%02d", s, h, m)
}
// Zone represents any form of zone offset: this could be a city or a fixed
@ -58,13 +80,54 @@ type Zone struct {
Offset *time.Location
}
// IsOffset is true when the Zone is an offset instead of a city
func (z Zone) IsOffset() bool {
return z.Offset != nil
}
// Name returns the name of the zone
func (z Zone) Name() string {
if z.IsOffset() {
return z.Offset.String()
} else if z.City != nil {
return z.City.FullName()
}
return ""
}
// Location returns the time.Location of the zone. Useful for other functions
func (z Zone) Location() *time.Location {
if z.IsOffset() {
return z.Offset
} else if z.City != nil {
loc, err := time.LoadLocation(z.City.Timezone)
if err != nil {
// This is a really bad situation
// TODO: validate this at boot time
l.Error("unable to find timezone", zap.String("timezone", z.City.Timezone))
}
return loc
}
return nil
}
// TimeOffset returns the timezone offset at a specific time
func (z Zone) TimeOffset(t time.Time) int {
if l := z.Location(); l != nil {
_, offset := t.In(l).Zone()
return offset
}
return -1 // TODO: better invalid handling
}
// ResolveZone resolves a zone string into a Zone
func ResolveZone(cities map[string]*data.City, zone string) (Zone, error) {
// Try parsing as a zone offset
offset, err := ParseZoneOffset(zone)
if err == nil {
offsetZone := time.FixedZone("UTC "+FormatZoneOffset(offset), offset)
return Zone{
Offset: offset,
Offset: offsetZone,
}, nil
}
// Parse as a city

View File

@ -8,10 +8,51 @@ import (
)
func TestParseZoneOffset(t *testing.T) {
loc, err := ParseZoneOffset("+08:00")
offset, err := ParseZoneOffset("+08:00")
if err != nil {
t.Errorf("want error %v, got error %v", nil, err)
} else {
want := 8*60*60 + 0*60
if offset != want {
t.Errorf("got %d, want %d", offset, want)
}
}
offset, err = ParseZoneOffset("-01:30")
if err != nil {
t.Errorf("want error %v, got error %v", nil, err)
} else {
want := -(1*60*60 + 30*60)
if offset != want {
t.Errorf("got %d, want %d", offset, want)
}
}
_, err = ParseZoneOffset("-0030")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
_, err = ParseZoneOffset("00:30")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
_, err = ParseZoneOffset("+08:60")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
_, err = ParseZoneOffset("+08:-6")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
offset, err = ParseZoneOffset("+08:00")
if err != nil {
t.Errorf("want error %v, got error %v", nil, err)
} else {
loc := time.FixedZone("UTC "+FormatZoneOffset(offset), offset)
time := time.Date(2020, time.November, 8, 23, 9, 0, 0, loc).Unix()
want := int64(1604848140)
if time != want {
@ -19,36 +60,17 @@ func TestParseZoneOffset(t *testing.T) {
}
}
loc, err = ParseZoneOffset("-00:30")
offset, err = ParseZoneOffset("-00:30")
if err != nil {
t.Errorf("want error %v, got error %v", nil, err)
} else {
loc := time.FixedZone("UTC "+FormatZoneOffset(offset), offset)
time := time.Date(2020, time.November, 8, 14, 39, 0, 0, loc).Unix()
want := int64(1604848140)
if time != want {
t.Errorf("got %d, want %d", time, want)
}
}
loc, err = ParseZoneOffset("-0030")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
loc, err = ParseZoneOffset("00:30")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
loc, err = ParseZoneOffset("+08:60")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
loc, err = ParseZoneOffset("+08:-6")
if err != ErrZoneOffsetInvalid {
t.Errorf("want error %v, got error %v", ErrZoneOffsetInvalid, err)
}
}
func TestSearchCities(t *testing.T) {
@ -109,9 +131,10 @@ func TestResolveZone(t *testing.T) {
if zone.City != nil {
t.Errorf("want City %v, got City %v", nil, zone.City)
}
wantOffset, _ := ParseZoneOffset("+04:00")
if zone.Offset.String() != wantOffset.String() {
t.Errorf("want Offset %v, got Offset %v", wantOffset, zone.Offset)
gotTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, zone.Offset)
wantTime := time.Date(2019, time.December, 31, 20, 0, 0, 0, time.UTC)
if !gotTime.Equal(wantTime) {
t.Errorf("want time %v, got time %v", wantTime, gotTime)
}
zone, err = ResolveZone(cities, "+04:80")