fix(dx): audit error handling, file I/O, and test coverage
- Create CLAUDE.md with package-specific conventions and commands - Replace fmt.Errorf with coreerr.E() in test mock (updater_test.go) - Replace os.ReadFile/os.WriteFile with go-io in build/main.go - Add unit tests for filterReleases, determineChannel, GetDownloadURL, formatVersionForDisplay, formatVersionForComparison, and default cases in startGitHubCheck/startHTTPCheck - All targeted functions now at 100% coverage Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
63e50757e9
commit
0efc1ebc80
6 changed files with 325 additions and 8 deletions
75
CLAUDE.md
Normal file
75
CLAUDE.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# CLAUDE.md — go-update
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code when working with the `go-update` package.
|
||||||
|
|
||||||
|
## Package Overview
|
||||||
|
|
||||||
|
`go-update` (`forge.lthn.ai/core/go-update`) is a **self-updater library** for Go applications. It supports updates from GitHub releases and generic HTTP endpoints, with configurable startup behaviour and version channel filtering.
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
go test -cover ./...
|
||||||
|
|
||||||
|
# Run a single test
|
||||||
|
go test -run TestName ./...
|
||||||
|
|
||||||
|
# Generate version.go from package.json
|
||||||
|
go generate ./...
|
||||||
|
|
||||||
|
# Vet and lint
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Update Sources
|
||||||
|
|
||||||
|
| Source | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| GitHub Releases | Fetches releases via GitHub API, filters by channel (stable/beta/alpha) |
|
||||||
|
| Generic HTTP | Fetches `latest.json` from a base URL with version + download URL |
|
||||||
|
|
||||||
|
### Key Types
|
||||||
|
|
||||||
|
- **`UpdateService`** — Configured service that checks for updates on startup
|
||||||
|
- **`GithubClient`** — Interface for GitHub API interactions (mockable for tests)
|
||||||
|
- **`Release`** / **`ReleaseAsset`** — GitHub release model
|
||||||
|
- **`GenericUpdateInfo`** — HTTP update endpoint model
|
||||||
|
|
||||||
|
### Testable Function Variables
|
||||||
|
|
||||||
|
Core update logic is exposed as `var` function values so tests can replace them:
|
||||||
|
|
||||||
|
- `NewGithubClient` — Factory for GitHub client (replace with mock)
|
||||||
|
- `DoUpdate` — Performs the actual binary update
|
||||||
|
- `CheckForNewerVersion`, `CheckForUpdates`, `CheckOnly` — GitHub update flow
|
||||||
|
- `CheckForUpdatesHTTP`, `CheckOnlyHTTP` — HTTP update flow
|
||||||
|
- `NewAuthenticatedClient` — HTTP client factory (supports `GITHUB_TOKEN`)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
All errors **must** use `coreerr.E()` from `forge.lthn.ai/core/go-log`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import coreerr "forge.lthn.ai/core/go-log"
|
||||||
|
|
||||||
|
return coreerr.E("FunctionName", "what failed", underlyingErr)
|
||||||
|
```
|
||||||
|
|
||||||
|
Never use `fmt.Errorf` or `errors.New`.
|
||||||
|
|
||||||
|
### File I/O
|
||||||
|
|
||||||
|
Use `forge.lthn.ai/core/go-io` for file operations, not `os.ReadFile`/`os.WriteFile`.
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
- **UK English** in comments and strings
|
||||||
|
- **Strict types**: All parameters and return types
|
||||||
|
- **Test naming**: `_Good`, `_Bad`, `_Ugly` suffix pattern
|
||||||
|
- **License**: EUPL-1.2
|
||||||
|
|
@ -3,32 +3,33 @@ package main
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
|
coreio "forge.lthn.ai/core/go-io"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Read package.json
|
// Read package.json
|
||||||
data, err := os.ReadFile("package.json")
|
data, err := coreio.Read(coreio.Local, "package.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Error reading package.json, skipping version file generation.")
|
fmt.Println("Error reading package.json, skipping version file generation.")
|
||||||
os.Exit(0)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse package.json
|
// Parse package.json
|
||||||
var pkg struct {
|
var pkg struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
if err := json.Unmarshal([]byte(data), &pkg); err != nil {
|
||||||
fmt.Println("Error parsing package.json, skipping version file generation.")
|
fmt.Println("Error parsing package.json, skipping version file generation.")
|
||||||
os.Exit(0)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the version file
|
// Create the version file
|
||||||
content := fmt.Sprintf("package updater\n\n// Generated by go:generate. DO NOT EDIT.\n\nconst PkgVersion = %q\n", pkg.Version)
|
content := fmt.Sprintf("package updater\n\n// Generated by go:generate. DO NOT EDIT.\n\nconst PkgVersion = %q\n", pkg.Version)
|
||||||
err = os.WriteFile("version.go", []byte(content), 0644)
|
err = coreio.Write(coreio.Local, "version.go", content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error writing version file: %v\n", err)
|
fmt.Printf("Error writing version file: %v\n", err)
|
||||||
os.Exit(1)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Generated version.go with version:", pkg.Version)
|
fmt.Println("Generated version.go with version:", pkg.Version)
|
||||||
|
|
|
||||||
236
github_internal_test.go
Normal file
236
github_internal_test.go
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilterReleases_Good(t *testing.T) {
|
||||||
|
releases := []Release{
|
||||||
|
{TagName: "v1.0.0-alpha.1", PreRelease: true},
|
||||||
|
{TagName: "v1.0.0-beta.1", PreRelease: true},
|
||||||
|
{TagName: "v1.0.0", PreRelease: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
channel string
|
||||||
|
wantTag string
|
||||||
|
}{
|
||||||
|
{"stable", "v1.0.0"},
|
||||||
|
{"alpha", "v1.0.0-alpha.1"},
|
||||||
|
{"beta", "v1.0.0-beta.1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.channel, func(t *testing.T) {
|
||||||
|
got := filterReleases(releases, tt.channel)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("expected release for channel %q, got nil", tt.channel)
|
||||||
|
}
|
||||||
|
if got.TagName != tt.wantTag {
|
||||||
|
t.Errorf("expected tag %q, got %q", tt.wantTag, got.TagName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterReleases_Bad(t *testing.T) {
|
||||||
|
releases := []Release{
|
||||||
|
{TagName: "v1.0.0", PreRelease: false},
|
||||||
|
}
|
||||||
|
got := filterReleases(releases, "alpha")
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("expected nil for non-matching channel, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterReleases_PreReleaseWithoutLabel(t *testing.T) {
|
||||||
|
releases := []Release{
|
||||||
|
{TagName: "v2.0.0-rc.1", PreRelease: true},
|
||||||
|
}
|
||||||
|
got := filterReleases(releases, "beta")
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected pre-release without alpha/beta label to match beta channel")
|
||||||
|
}
|
||||||
|
if got.TagName != "v2.0.0-rc.1" {
|
||||||
|
t.Errorf("expected tag %q, got %q", "v2.0.0-rc.1", got.TagName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetermineChannel_Good(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
tag string
|
||||||
|
isPreRelease bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"v1.0.0", false, "stable"},
|
||||||
|
{"v1.0.0-alpha.1", false, "alpha"},
|
||||||
|
{"v1.0.0-ALPHA.1", false, "alpha"},
|
||||||
|
{"v1.0.0-beta.1", false, "beta"},
|
||||||
|
{"v1.0.0-BETA.1", false, "beta"},
|
||||||
|
{"v1.0.0-rc.1", true, "beta"},
|
||||||
|
{"v1.0.0-rc.1", false, "stable"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.tag, func(t *testing.T) {
|
||||||
|
got := determineChannel(tt.tag, tt.isPreRelease)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("determineChannel(%q, %v) = %q, want %q", tt.tag, tt.isPreRelease, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDownloadURL_Good(t *testing.T) {
|
||||||
|
osName := runtime.GOOS
|
||||||
|
archName := runtime.GOARCH
|
||||||
|
|
||||||
|
release := &Release{
|
||||||
|
TagName: "v1.2.3",
|
||||||
|
Assets: []ReleaseAsset{
|
||||||
|
{Name: "app-" + osName + "-" + archName, DownloadURL: "https://example.com/full-match"},
|
||||||
|
{Name: "app-" + osName, DownloadURL: "https://example.com/os-only"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := GetDownloadURL(release, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if url != "https://example.com/full-match" {
|
||||||
|
t.Errorf("expected full match URL, got %q", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDownloadURL_OSOnlyFallback(t *testing.T) {
|
||||||
|
osName := runtime.GOOS
|
||||||
|
|
||||||
|
release := &Release{
|
||||||
|
TagName: "v1.2.3",
|
||||||
|
Assets: []ReleaseAsset{
|
||||||
|
{Name: "app-other-other", DownloadURL: "https://example.com/other"},
|
||||||
|
{Name: "app-" + osName + "-other", DownloadURL: "https://example.com/os-only"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := GetDownloadURL(release, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if url != "https://example.com/os-only" {
|
||||||
|
t.Errorf("expected OS-only fallback URL, got %q", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDownloadURL_WithFormat(t *testing.T) {
|
||||||
|
release := &Release{TagName: "v1.2.3"}
|
||||||
|
|
||||||
|
url, err := GetDownloadURL(release, "https://example.com/{tag}/{os}/{arch}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "https://example.com/v1.2.3/" + runtime.GOOS + "/" + runtime.GOARCH
|
||||||
|
if url != expected {
|
||||||
|
t.Errorf("expected %q, got %q", expected, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDownloadURL_Bad(t *testing.T) {
|
||||||
|
// nil release
|
||||||
|
_, err := GetDownloadURL(nil, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nil release")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No matching assets
|
||||||
|
release := &Release{
|
||||||
|
TagName: "v1.2.3",
|
||||||
|
Assets: []ReleaseAsset{
|
||||||
|
{Name: "app-unknownos-unknownarch", DownloadURL: "https://example.com/other"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err = GetDownloadURL(release, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when no suitable asset found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatVersionForComparison(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"1.0.0", "v1.0.0"},
|
||||||
|
{"v1.0.0", "v1.0.0"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := formatVersionForComparison(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("formatVersionForComparison(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatVersionForDisplay(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
version string
|
||||||
|
force bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"1.0.0", true, "v1.0.0"},
|
||||||
|
{"v1.0.0", true, "v1.0.0"},
|
||||||
|
{"v1.0.0", false, "1.0.0"},
|
||||||
|
{"1.0.0", false, "1.0.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.version+"_force_"+boolStr(tt.force), func(t *testing.T) {
|
||||||
|
got := formatVersionForDisplay(tt.version, tt.force)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("formatVersionForDisplay(%q, %v) = %q, want %q", tt.version, tt.force, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolStr(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartGitHubCheck_UnknownMode(t *testing.T) {
|
||||||
|
s := &UpdateService{
|
||||||
|
config: UpdateServiceConfig{
|
||||||
|
CheckOnStartup: StartupCheckMode(99),
|
||||||
|
},
|
||||||
|
isGitHub: true,
|
||||||
|
owner: "owner",
|
||||||
|
repo: "repo",
|
||||||
|
}
|
||||||
|
err := s.Start()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for unknown startup check mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartHTTPCheck_UnknownMode(t *testing.T) {
|
||||||
|
s := &UpdateService{
|
||||||
|
config: UpdateServiceConfig{
|
||||||
|
RepoURL: "https://example.com/updates",
|
||||||
|
CheckOnStartup: StartupCheckMode(99),
|
||||||
|
},
|
||||||
|
isGitHub: false,
|
||||||
|
}
|
||||||
|
err := s.Start()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for unknown startup check mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
1
go.mod
1
go.mod
|
|
@ -4,6 +4,7 @@ go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
forge.lthn.ai/core/cli v0.3.6
|
forge.lthn.ai/core/cli v0.3.6
|
||||||
|
forge.lthn.ai/core/go-io v0.1.5
|
||||||
forge.lthn.ai/core/go-log v0.0.4
|
forge.lthn.ai/core/go-log v0.0.4
|
||||||
github.com/Snider/Borg v0.2.0
|
github.com/Snider/Borg v0.2.0
|
||||||
github.com/minio/selfupdate v0.6.0
|
github.com/minio/selfupdate v0.6.0
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -11,6 +11,8 @@ forge.lthn.ai/core/go-i18n v0.1.6 h1:Z9h6sEZsgJmWlkkq3ZPZyfgWipeeqN5lDCpzltpamHU
|
||||||
forge.lthn.ai/core/go-i18n v0.1.6/go.mod h1:C6CbwdN7sejTx/lbutBPrxm77b8paMHBO6uHVLHOdqQ=
|
forge.lthn.ai/core/go-i18n v0.1.6/go.mod h1:C6CbwdN7sejTx/lbutBPrxm77b8paMHBO6uHVLHOdqQ=
|
||||||
forge.lthn.ai/core/go-inference v0.1.5 h1:Az/Euv1DusJQJz/Eca0Ey7sVXQkFLPHW0TBrs9g+Qwg=
|
forge.lthn.ai/core/go-inference v0.1.5 h1:Az/Euv1DusJQJz/Eca0Ey7sVXQkFLPHW0TBrs9g+Qwg=
|
||||||
forge.lthn.ai/core/go-inference v0.1.5/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
forge.lthn.ai/core/go-inference v0.1.5/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||||
|
forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM=
|
||||||
|
forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI=
|
||||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockGithubClient is a mock implementation of the GithubClient interface for testing.
|
// mockGithubClient is a mock implementation of the GithubClient interface for testing.
|
||||||
|
|
@ -34,7 +36,7 @@ func (m *mockGithubClient) GetPublicRepos(ctx context.Context, userOrOrg string)
|
||||||
if m.getPublicRepos != nil {
|
if m.getPublicRepos != nil {
|
||||||
return m.getPublicRepos(ctx, userOrOrg)
|
return m.getPublicRepos(ctx, userOrOrg)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("GetPublicRepos not implemented")
|
return nil, coreerr.E("mockGithubClient.GetPublicRepos", "not implemented", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExampleCheckForNewerVersion() {
|
func ExampleCheckForNewerVersion() {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue