Repository: favonia/cloudflare-ddns
Pull request #64 (2021-08-08 13:32:23)

test(updator): full coverage of package updator

codecov[bot]: # [Codecov](https://codecov.io/gh/favonia/cloudflare-ddns/pull/64?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) Report
> Merging [#64](https://codecov.io/gh/favonia/cloudflare-ddns/pull/64?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (6d76f08) into [main](https://codecov.io/gh/favonia/cloudflare-ddns/commit/8463fab23fe3c7d8992d5dad2bd0a2afb1ed18b5?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (8463fab) will **increase** coverage by `2.94%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/favonia/cloudflare-ddns/pull/64/graphs/tree.svg?width=650&height=150&src=pr&token=90Cwhy7OkI&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia)](https://codecov.io/gh/favonia/cloudflare-ddns/pull/64?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia)

```diff
@@            Coverage Diff             @@
##             main      #64      +/-   ##
==========================================
+ Coverage   35.43%   38.38%   +2.94%     
==========================================
  Files          20       20              
  Lines        1188     1188              
==========================================
+ Hits          421      456      +35     
+ Misses        766      724      -42     
- Partials        1        8       +7     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `38.38% <100.00%> (+2.94%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/favonia/cloudflare-ddns/pull/64?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Coverage Δ | |
|---|---|---|
| [internal/updator/updator.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/64/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvdXBkYXRvci91cGRhdG9yLmdv) | `42.68% <100.00%> (+42.68%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/favonia/cloudflare-ddns/pull/64?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/favonia/cloudflare-ddns/pull/64?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia). Last update [8463fab...6d76f08](https://codecov.io/gh/favonia/cloudflare-ddns/pull/64?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).


Patch:
diff --git a/internal/updator/updator.go b/internal/updator/updator.go
index ee4f66d..67186c8 100644
--- a/internal/updator/updator.go
+++ b/internal/updator/updator.go
@@ -3,6 +3,7 @@ package updator
 import (
 	"context"
 	"net"
+	"sort"
 
 	"github.com/favonia/cloudflare-ddns/internal/api"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
@@ -20,7 +21,7 @@ type Args struct {
 	Proxied   bool
 }
 
-func SplitRecords(rmap map[string]net.IP, target net.IP) (matchedIDs, unmatchedIDs []string) {
+func splitRecords(rmap map[string]net.IP, target net.IP) (matchedIDs, unmatchedIDs []string) {
 	for id, ip := range rmap {
 		if ip.Equal(target) {
 			matchedIDs = append(matchedIDs, id)
@@ -29,6 +30,13 @@ func SplitRecords(rmap map[string]net.IP, target net.IP) (matchedIDs, unmatchedI
 		}
 	}
 
+	// This is to make Do deterministic so that this package is easier to test.
+	// Otherwise, sorting is not needed. The performance penality should be small
+	// because in most cases the total number of (matched and unmached) records
+	// would be zero or one.
+	sort.Strings(matchedIDs)
+	sort.Strings(unmatchedIDs)
+
 	return matchedIDs, unmatchedIDs
 }
 
@@ -41,7 +49,7 @@ func Do(ctx context.Context, indent pp.Indent, quiet quiet.Quiet, args *Args) bo
 		return false
 	}
 
-	matchedIDs, unmatchedIDs := SplitRecords(rs, args.IP)
+	matchedIDs, unmatchedIDs := splitRecords(rs, args.IP)
 
 	// whether there was already an up-to-date record
 	uptodate := false


Test patch:
diff --git a/internal/updator/updator_test.go b/internal/updator/updator_test.go
new file mode 100644
index 0000000..2671248
--- /dev/null
+++ b/internal/updator/updator_test.go
@@ -0,0 +1,429 @@
+package updator_test
+
+import (
+	"context"
+	"net"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/api"
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+	"github.com/favonia/cloudflare-ddns/internal/quiet"
+	"github.com/favonia/cloudflare-ddns/internal/updator"
+)
+
+type eventType int
+
+const (
+	eventList eventType = iota
+	eventDelete
+	eventUpdate
+	eventCreate
+)
+
+type interaction struct {
+	event     eventType
+	arguments []interface{}
+	values    []interface{}
+}
+
+type mockHandle struct {
+	t      *testing.T
+	script []interaction
+}
+
+func (m *mockHandle) Call(event eventType, numValues int, arguments ...interface{}) []interface{} {
+	require.Greater(m.t, len(m.script), 0)
+
+	require.Equal(m.t, m.script[0].event, event)
+	require.Equal(m.t, len(m.script[0].values), numValues)
+	require.Equal(m.t, m.script[0].arguments, arguments)
+
+	values := m.script[0].values
+	m.script = m.script[1:]
+
+	return values
+}
+
+func (m *mockHandle) IsExhausted() bool {
+	return len(m.script) == 0
+}
+
+func (m *mockHandle) ListRecords(_ context.Context, _ pp.Indent,
+	domain api.FQDN, ipNet ipnet.Type) (map[string]net.IP, bool) {
+	values := m.Call(eventList, 2, domain, ipNet)
+
+	val0, _ := values[0].(map[string]net.IP)
+	val1, _ := values[1].(bool)
+
+	return val0, val1
+}
+
+func (m *mockHandle) DeleteRecord(_ context.Context, _ pp.Indent,
+	domain api.FQDN, ipNet ipnet.Type, id string) bool {
+	values := m.Call(eventDelete, 1, domain, ipNet, id)
+	val0, _ := values[0].(bool)
+	return val0
+}
+
+func (m *mockHandle) UpdateRecord(ctx context.Context, indent pp.Indent,
+	domain api.FQDN, ipNet ipnet.Type, id string, ip net.IP) bool {
+	values := m.Call(eventUpdate, 1, domain, ipNet, id, ip)
+	val0, _ := values[0].(bool)
+	return val0
+}
+
+func (m *mockHandle) CreateRecord(ctx context.Context, indent pp.Indent,
+	domain api.FQDN, ipNet ipnet.Type, ip net.IP, ttl int, proxied bool) (string, bool) {
+	values := m.Call(eventCreate, 2, domain, ipNet, ip, ttl, proxied)
+	val0, _ := values[0].(string)
+	val1, _ := values[1].(bool)
+	return val0, val1
+}
+
+func (m *mockHandle) FlushCache() {
+	require.FailNow(m.t, "updator should never call FlushCache directly")
+}
+
+//nolint: funlen
+func TestDo(t *testing.T) {
+	t.Parallel()
+
+	const (
+		quiet     = quiet.VERBOSE
+		domain    = api.FQDN("sub.test.org")
+		ipNetwork = ipnet.IP6
+		record1   = "record1"
+		record2   = "record2"
+		record3   = "record3"
+		ttl       = 100
+		proxied   = true
+	)
+	var (
+		ip1 = net.ParseIP("::1")
+		ip2 = net.ParseIP("::2")
+	)
+
+	for name, tc := range map[string]struct {
+		ip      net.IP
+		ttl     api.TTL
+		proxied bool
+		script  []interaction
+		ok      bool
+	}{
+		"0-nil": {
+			ip: nil,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{}, true},
+				},
+			},
+			ok: true,
+		},
+		"0": {
+			ip: ip1,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{}, true},
+				},
+				{
+					event:     eventCreate,
+					arguments: []interface{}{domain, ipNetwork, ip1, ttl, proxied},
+					values:    []interface{}{record1, true},
+				},
+			},
+			ok: true,
+		},
+		"1unmatched": {
+			ip: ip1,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{record1: ip2}, true},
+				},
+				{
+					event:     eventUpdate,
+					arguments: []interface{}{domain, ipNetwork, record1, ip1},
+					values:    []interface{}{true},
+				},
+			},
+			ok: true,
+		},
+		"1unmatched-updatefail": {
+			ip: ip1,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{record1: ip2}, true},
+				},
+				{
+					event:     eventUpdate,
+					arguments: []interface{}{domain, ipNetwork, record1, ip1},
+					values:    []interface{}{false},
+				},
+				{
+					event:     eventDelete,
+					arguments: []interface{}{domain, ipNetwork, record1},
+					values:    []interface{}{true},
+				},
+				{
+					event:     eventCreate,
+					arguments: []interface{}{domain, ipNetwork, ip1, ttl, proxied},
+					values:    []interface{}{record2, true},
+				},
+			},
+			ok: true,
+		},
+		"1unmatched-nil": {
+			ip: nil,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{record1: ip1}, true},
+				},
+				{
+					event:     eventDelete,
+					arguments: []interface{}{domain, ipNetwork, record1},
+					values:    []interface{}{true},
+				},
+			},
+			ok: true,
+		},
+		"1matched": {
+			ip: ip1,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{record1: ip1}, true},
+				},
+			},
+			ok: true,
+		},
+		"2matched": {
+			ip: ip1,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{record1: ip1, record2: ip1}, true},
+				},
+				{
+					event:     eventDelete,
+					arguments: []interface{}{domain, ipNetwork, record2},
+					values:    []interface{}{true},
+				},
+			},
+			ok: true,
+		},
+		"2matched-deletefail": {
+			ip: ip1,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{record1: ip1, record2: ip1}, true},
+				},
+				{
+					event:     eventDelete,
+					arguments: []interface{}{domain, ipNetwork, record2},
+					values:    []interface{}{false},
+				},
+			},
+			ok: true,
+		},
+		"2unmatched": {
+			ip: ip1,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{record1: ip2, record2: ip2}, true},
+				},
+				{
+					event:     eventUpdate,
+					arguments: []interface{}{domain, ipNetwork, record1, ip1},
+					values:    []interface{}{true},
+				},
+				{
+					event:     eventDelete,
+					arguments: []interface{}{domain, ipNetwork, record2},
+					values:    []interface{}{true},
+				},
+			},
+			ok: true,
+		},
+		"2unmatched-updatefail": {
+			ip: ip1,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{record1: ip2, record2: ip2}, true},
+				},
+				{
+					event:     eventUpdate,
+					arguments: []interface{}{domain, ipNetwork, record1, ip1},
+					values:    []interface{}{false},
+				},
+				{
+					event:     eventDelete,
+					arguments: []interface{}{domain, ipNetwork, record1},
+					values:    []interface{}{true},
+				},
+				{
+					event:     eventUpdate,
+					arguments: []interface{}{domain, ipNetwork, record2, ip1},
+					values:    []interface{}{true},
+				},
+			},
+			ok: true,
+		},
+		"2unmatched-updatefailtwice": { //nolint: dupl // they are similar but different tests
+			ip: ip1,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{record1: ip2, record2: ip2}, true},
+				},
+				{
+					event:     eventUpdate,
+					arguments: []interface{}{domain, ipNetwork, record1, ip1},
+					values:    []interface{}{false},
+				},
+				{
+					event:     eventDelete,
+					arguments: []interface{}{domain, ipNetwork, record1},
+					values:    []interface{}{true},
+				},
+				{
+					event:     eventUpdate,
+					arguments: []interface{}{domain, ipNetwork, record2, ip1},
+					values:    []interface{}{false},
+				},
+				{
+					event:     eventDelete,
+					arguments: []interface{}{domain, ipNetwork, record2},
+					values:    []interface{}{true},
+				},
+				{
+					event:     eventCreate,
+					arguments: []interface{}{domain, ipNetwork, ip1, ttl, proxied},
+					values:    []interface{}{record3, true},
+				},
+			},
+			ok: true,
+		},
+		"2unmatched-updatefail-deletefail-updatefail": { //nolint: dupl // they are similar but different tests
+			ip: ip1,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{record1: ip2, record2: ip2}, true},
+				},
+				{
+					event:     eventUpdate,
+					arguments: []interface{}{domain, ipNetwork, record1, ip1},
+					values:    []interface{}{false},
+				},
+				{
+					event:     eventDelete,
+					arguments: []interface{}{domain, ipNetwork, record1},
+					values:    []interface{}{false},
+				},
+				{
+					event:     eventUpdate,
+					arguments: []interface{}{domain, ipNetwork, record2, ip1},
+					values:    []interface{}{false},
+				},
+				{
+					event:     eventDelete,
+					arguments: []interface{}{domain, ipNetwork, record2},
+					values:    []interface{}{true},
+				},
+				{
+					event:     eventCreate,
+					arguments: []interface{}{domain, ipNetwork, ip1, ttl, proxied},
+					values:    []interface{}{record3, true},
+				},
+			},
+			ok: false,
+		},
+		"2unmatched-updatefailtwice-createfail": { //nolint: dupl // they are similar but different tests
+			ip: ip1,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{map[string]net.IP{record1: ip2, record2: ip2}, true},
+				},
+				{
+					event:     eventUpdate,
+					arguments: []interface{}{domain, ipNetwork, record1, ip1},
+					values:    []interface{}{false},
+				},
+				{
+					event:     eventDelete,
+					arguments: []interface{}{domain, ipNetwork, record1},
+					values:    []interface{}{true},
+				},
+				{
+					event:     eventUpdate,
+					arguments: []interface{}{domain, ipNetwork, record2, ip1},
+					values:    []interface{}{false},
+				},
+				{
+					event:     eventDelete,
+					arguments: []interface{}{domain, ipNetwork, record2},
+					values:    []interface{}{true},
+				},
+				{
+					event:     eventCreate,
+					arguments: []interface{}{domain, ipNetwork, ip1, ttl, proxied},
+					values:    []interface{}{record3, false},
+				},
+			},
+			ok: false,
+		},
+		"listfail": {
+			ip: ip1,
+			script: []interaction{
+				{
+					event:     eventList,
+					arguments: []interface{}{domain, ipNetwork},
+					values:    []interface{}{nil, false},
+				},
+			},
+			ok: false,
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			m := &mockHandle{t: t, script: tc.script}
+			ok := updator.Do(context.Background(), 3,
+				quiet,
+				&updator.Args{
+					Handle:    m,
+					IPNetwork: ipNetwork,
+					IP:        tc.ip,
+					Domain:    domain,
+					TTL:       ttl,
+					Proxied:   proxied,
+				})
+			require.Equal(t, tc.ok, ok)
+			require.True(t, m.IsExhausted())
+		})
+	}
+}