Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions internal/client/targets.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ func (c *Client) HouseholdUserTargets(ctx context.Context) ([]HouseholdUserTarge
if err := c.do(ctx, http.MethodGet, path, query, nil, &deviceRes); err != nil {
return nil, err
}

sideByUser := sideAssignmentsFromDevice(deviceRes.Result.LeftUserID, deviceRes.Result.RightUserID, deviceRes.Result.AwaySides)

userIDs := orderedUniqueStrings(
deviceRes.Result.LeftUserID,
deviceRes.Result.RightUserID,
Expand All @@ -80,7 +83,7 @@ func (c *Client) HouseholdUserTargets(ctx context.Context) ([]HouseholdUserTarge
}
targets = append(targets, HouseholdUserTarget{
UserID: userRes.User.UserID,
Side: strings.ToLower(strings.TrimSpace(userRes.User.CurrentDevice.Side)),
Side: resolveTargetSide(sideByUser[userRes.User.UserID], userRes.User.CurrentDevice.Side),
FirstName: userRes.User.FirstName,
LastName: userRes.User.LastName,
Email: userRes.User.Email,
Expand All @@ -92,6 +95,45 @@ func (c *Client) HouseholdUserTargets(ctx context.Context) ([]HouseholdUserTarge
return targets, nil
}

// sideAssignmentsFromDevice builds a userID -> side map from the /devices payload.
// In Away mode the top-level leftUserId/rightUserId come back empty and the
// real IDs are stashed inside awaySides as {"leftUserId":"…","rightUserId":"…"}.
func sideAssignmentsFromDevice(leftUserID, rightUserID string, awaySides map[string]string) map[string]string {
if leftUserID == "" {
leftUserID = awaySides["leftUserId"]
}
if rightUserID == "" {
rightUserID = awaySides["rightUserId"]
}
out := map[string]string{}
switch {
case leftUserID != "" && rightUserID != "" && leftUserID == rightUserID:
out[leftUserID] = "solo"
case leftUserID != "" && rightUserID != "":
out[leftUserID] = "left"
out[rightUserID] = "right"
case leftUserID != "" && rightUserID == "":
out[leftUserID] = "solo"
case leftUserID == "" && rightUserID != "":
out[rightUserID] = "solo"
}
return out
}

// resolveTargetSide prefers the device-level assignment and ignores
// user.currentDevice.side when it is the Away-mode sentinel.
func resolveTargetSide(deviceAssigned, userReported string) string {
if deviceAssigned != "" {
return deviceAssigned
}
candidate := strings.ToLower(strings.TrimSpace(userReported))
switch candidate {
case "left", "right", "solo":
return candidate
}
return ""
}

// ResolveHouseholdSide resolves a single user target for left/right/solo side-aware commands.
func ResolveHouseholdSide(targets []HouseholdUserTarget, side string) (*HouseholdUserTarget, error) {
side = strings.ToLower(strings.TrimSpace(side))
Expand All @@ -104,7 +146,8 @@ func ResolveHouseholdSide(targets []HouseholdUserTarget, side string) (*Househol
matches := []HouseholdUserTarget{}
available := []string{}
for _, target := range targets {
if target.Side != "" {
switch target.Side {
case "left", "right", "solo":
available = appendUniqueString(available, target.Side)
}
if target.Side == side {
Expand Down
55 changes: 55 additions & 0 deletions internal/client/targets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,61 @@ func TestHouseholdUserTargets(t *testing.T) {
}
}

func TestHouseholdUserTargetsUsesDeviceMappingInAwayMode(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/users/me", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"user":{"devices":["dev-1"]}}`))
})
mux.HandleFunc("/devices/dev-1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// In Away mode the API blanks top-level leftUserId/rightUserId and
// stashes them inside awaySides with the original field names.
w.Write([]byte(`{"result":{"awaySides":{"leftUserId":"left-user","rightUserId":"right-user"}}}`))
})
// In Away mode the user payload reports side "away" for everyone.
mux.HandleFunc("/users/left-user", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"user":{"userId":"left-user","firstName":"Igor","lastName":"Left","email":"left@example.com","currentDevice":{"side":"away"}}}`))
})
mux.HandleFunc("/users/right-user", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"user":{"userId":"right-user","firstName":"Renata","lastName":"Right","email":"right@example.com","currentDevice":{"side":"away"}}}`))
})

srv := httptest.NewServer(mux)
defer srv.Close()

c := New("email", "pass", "", "", "")
c.BaseURL = srv.URL
c.token = "t"
c.tokenExp = time.Now().Add(time.Hour)
c.HTTP = srv.Client()

targets, err := c.HouseholdUserTargets(context.Background())
if err != nil {
t.Fatalf("HouseholdUserTargets: %v", err)
}
if len(targets) != 2 {
t.Fatalf("len(targets) = %d, want 2", len(targets))
}
sideByID := map[string]string{}
for _, target := range targets {
sideByID[target.UserID] = target.Side
}
if sideByID["left-user"] != "left" || sideByID["right-user"] != "right" {
t.Fatalf("side map = %+v, want left/right", sideByID)
}

resolved, err := ResolveHouseholdSide(targets, "left")
if err != nil {
t.Fatalf("ResolveHouseholdSide left: %v", err)
}
if resolved.UserID != "left-user" {
t.Fatalf("left target user = %q, want left-user", resolved.UserID)
}
}

func TestHouseholdUserTargetsInfersSoloWhenOnlyOneUserExists(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/users/me", func(w http.ResponseWriter, r *http.Request) {
Expand Down
7 changes: 5 additions & 2 deletions internal/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,18 @@ import (
func useTempKeyring(t *testing.T) func() {
t.Helper()
tmp := t.TempDir()
restore := tokencache.SetOpenKeyringForTest(func() (keyring.Keyring, error) {
opener := func() (keyring.Keyring, error) {
return keyring.Open(keyring.Config{
ServiceName: "eightctl-test",
AllowedBackends: []keyring.BackendType{keyring.FileBackend},
FileDir: filepath.Join(tmp, "keyring"),
FilePasswordFunc: func(_ string) (string, error) { return "test-pass", nil },
})
})
}
restore := tokencache.SetOpenKeyringForTest(opener)
restoreFile := tokencache.SetOpenFileKeyringForTest(opener)
t.Cleanup(restore)
t.Cleanup(restoreFile)
return restore
}

Expand Down
58 changes: 52 additions & 6 deletions internal/cmd/timezone.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,66 @@
package cmd

import (
"os"
"path/filepath"
"runtime"
"strings"
"time"
)

func resolveAPITimezone(value string) (string, error) {
tz := strings.TrimSpace(value)
if tz == "" || strings.EqualFold(tz, "local") {
tz = strings.TrimSpace(time.Now().Location().String())
if tz != "" && !strings.EqualFold(tz, "local") {
return tz, nil
}
if tz == "" || strings.EqualFold(tz, "local") {
logger.Warn("system local timezone is not an IANA zone; falling back to UTC for API queries")
return "UTC", nil
if iana := localIANA(); iana != "" {
return iana, nil
}
return tz, nil
if loc := strings.TrimSpace(time.Now().Location().String()); loc != "" && !strings.EqualFold(loc, "local") {
return loc, nil
}
logger.Warn("system local timezone is not an IANA zone; falling back to UTC for API queries")
return "UTC", nil
}

// localIANA is overridable in tests.
var localIANA = defaultLocalIANA

// defaultLocalIANA discovers the IANA zone name the OS considers local. Go's
// time.Local.String() reports "Local" when TZ is unset, which the Eight Sleep
// API rejects, so we read the platform-specific source of truth.
func defaultLocalIANA() string {
if tz := strings.TrimSpace(os.Getenv("TZ")); tz != "" && !strings.EqualFold(tz, "local") {
return tz
}
switch runtime.GOOS {
case "darwin":
if target, err := os.Readlink("/etc/localtime"); err == nil {
if zone := extractZoneinfoSuffix(target); zone != "" {
return zone
}
}
case "linux":
if b, err := os.ReadFile("/etc/timezone"); err == nil {
if zone := strings.TrimSpace(string(b)); zone != "" {
return zone
}
}
if target, err := filepath.EvalSymlinks("/etc/localtime"); err == nil {
if zone := extractZoneinfoSuffix(target); zone != "" {
return zone
}
}
}
return ""
}

func extractZoneinfoSuffix(path string) string {
const marker = "zoneinfo/"
if idx := strings.Index(path, marker); idx >= 0 {
return path[idx+len(marker):]
}
return ""
}

func currentDate() string {
Expand Down
36 changes: 34 additions & 2 deletions internal/cmd/timezone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,28 @@ func TestResolveAPITimezoneExplicit(t *testing.T) {
}
}

func TestResolveAPITimezoneUsesLocalIANAWhenValueIsLocal(t *testing.T) {
orig := localIANA
localIANA = func() string { return "America/Los_Angeles" }
t.Cleanup(func() { localIANA = orig })

got, err := resolveAPITimezone("local")
if err != nil {
t.Fatalf("resolveAPITimezone: %v", err)
}
if got != "America/Los_Angeles" {
t.Fatalf("timezone = %q, want America/Los_Angeles", got)
}
}

func TestResolveAPITimezoneFallsBackToUTCWhenLocalIsUnknown(t *testing.T) {
original := time.Local
origLocal := time.Local
time.Local = time.FixedZone("Local", 0)
t.Cleanup(func() { time.Local = original })
t.Cleanup(func() { time.Local = origLocal })

origIANA := localIANA
localIANA = func() string { return "" }
t.Cleanup(func() { localIANA = origIANA })

got, err := resolveAPITimezone("local")
if err != nil {
Expand All @@ -28,3 +46,17 @@ func TestResolveAPITimezoneFallsBackToUTCWhenLocalIsUnknown(t *testing.T) {
t.Fatalf("timezone = %q, want UTC", got)
}
}

func TestExtractZoneinfoSuffix(t *testing.T) {
cases := map[string]string{
"/var/db/timezone/zoneinfo/America/New_York": "America/New_York",
"/private/var/db/timezone/tz/2024a.1.0/zoneinfo/Etc/UTC": "Etc/UTC",
"/usr/share/zoneinfo/Europe/Berlin": "Europe/Berlin",
"no-zoneinfo-here": "",
}
for input, want := range cases {
if got := extractZoneinfoSuffix(input); got != want {
t.Errorf("extractZoneinfoSuffix(%q) = %q, want %q", input, got, want)
}
}
}
Loading
Loading