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
123 changes: 123 additions & 0 deletions e2e/schedule.bats
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,83 @@

load test_helper

start_reports_schedule_stub() {
REPORTS_STUB_LOG="$TEST_TEMP_DIR/reports-schedule-stub.log"
REPORTS_STUB_PORT_FILE="$TEST_TEMP_DIR/reports-schedule-stub.port"

local python_bin
if command -v python3 >/dev/null 2>&1; then
python_bin=python3
elif command -v python >/dev/null 2>&1; then
python_bin=python
else
echo "Error: neither python3 nor python is available in PATH; cannot start reports schedule stub" >&2
return 1
fi

"$python_bin" - <<'PY' "$REPORTS_STUB_PORT_FILE" "$REPORTS_STUB_LOG" &
import http.server
import json
import socketserver
import sys

port_file = sys.argv[1]
log_file = sys.argv[2]

class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
with open(log_file, 'a', encoding='utf-8') as f:
f.write(self.path + '\n')
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({
'schedule_entries': [],
'recurring_schedule_entry_occurrences': [],
'assignables': [],
}).encode())

def log_message(self, format, *args):
pass

with socketserver.TCPServer(('127.0.0.1', 0), Handler) as server:
with open(port_file, 'w', encoding='utf-8') as f:
f.write(str(server.server_address[1]))
server.serve_forever()
PY
REPORTS_STUB_PID=$!

for _ in $(seq 1 50); do
[[ -s "$REPORTS_STUB_PORT_FILE" ]] && break
sleep 0.1
done

if [[ ! -s "$REPORTS_STUB_PORT_FILE" ]]; then
echo "failed to start reports schedule stub" >&2
return 1
fi

export BASECAMP_BASE_URL="http://127.0.0.1:$(cat "$REPORTS_STUB_PORT_FILE")"
}

stop_reports_schedule_stub() {
if [[ -n "${REPORTS_STUB_PID:-}" ]]; then
kill "$REPORTS_STUB_PID" 2>/dev/null || true
wait "$REPORTS_STUB_PID" 2>/dev/null || true
unset REPORTS_STUB_PID
fi
}

reports_schedule_request_path() {
local request_path
request_path=$(grep '/reports/schedules/upcoming.json' "$REPORTS_STUB_LOG")
[[ -n "$request_path" ]]
local count
count=$(grep -c '/reports/schedules/upcoming.json' "$REPORTS_STUB_LOG")
[[ "$count" -eq 1 ]]
printf '%s\n' "$request_path"
}


# Flag parsing errors

Expand Down Expand Up @@ -152,3 +229,49 @@ load test_helper
run basecamp schedule foobar
# Command may show help or require project - just verify it runs
}


# reports schedule flag defaults

@test "reports schedule --help shows default window in flag descriptions" {
create_credentials
create_global_config '{"account_id": 99999}'

run basecamp reports schedule --help
assert_success
assert_output_contains "default: today"
assert_output_contains "default: +30"
}

@test "reports schedule without flags sends default window to API" {
start_reports_schedule_stub
create_credentials
create_global_config '{"account_id": 99999}'

run basecamp reports schedule --json
stop_reports_schedule_stub

assert_success
assert_json_value '.ok' 'true'

request_path=$(reports_schedule_request_path)
[[ "$request_path" == *"/99999/reports/schedules/upcoming.json?"* ]]
[[ "$request_path" == *"window_starts_on="* ]]
[[ "$request_path" == *"window_ends_on="* ]]
}

@test "reports schedule --start without --end anchors default end to start" {
start_reports_schedule_stub
create_credentials
create_global_config '{"account_id": 99999}'

run basecamp reports schedule --start 2099-01-01 --json
stop_reports_schedule_stub

assert_success
assert_json_value '.ok' 'true'

request_path=$(reports_schedule_request_path)
[[ "$request_path" == *"window_starts_on=2099-01-01"* ]]
[[ "$request_path" == *"window_ends_on=2099-01-31"* ]]
}
11 changes: 10 additions & 1 deletion e2e/smoke/smoke_reports.bats
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,16 @@ setup_file() {

@test "reports schedule returns schedule entries" {
run_smoke basecamp reports schedule --json
# 400 on some dev environments where schedule reports aren't configured
# API error (exit 7) can occur in dev environments where the reports feature
# is not fully configured. A missing-parameter 400 is no longer possible here
# because --start and --end both have defaults (today and +30 respectively).
[[ "$status" -eq 7 ]] && mark_unverifiable "Schedule reports not available in this environment"
assert_success
assert_json_value '.ok' 'true'
}

@test "reports schedule with explicit window returns schedule entries" {
run_smoke basecamp reports schedule --start today --end "+7" --json
[[ "$status" -eq 7 ]] && mark_unverifiable "Schedule reports not available in this environment"
assert_success
assert_json_value '.ok' 'true'
Expand Down
40 changes: 28 additions & 12 deletions internal/commands/reports.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"strconv"
"strings"
"time"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -252,6 +253,22 @@ Todos are grouped into categories:
}
}

func resolveReportsScheduleWindow(startDate, endDate string, now time.Time) (string, string) {
if startDate == "" {
startDate = "today"
}

parsedStart := dateparse.ParseFrom(startDate, now)
if endDate == "" {
if start, err := time.Parse("2006-01-02", parsedStart); err == nil {
return parsedStart, start.AddDate(0, 0, 30).Format("2006-01-02")
}
return parsedStart, dateparse.ParseFrom("+30", now)
}

return parsedStart, dateparse.ParseFrom(endDate, now)
}

func newReportsScheduleCmd() *cobra.Command {
var startDate string
var endDate string
Expand All @@ -261,23 +278,22 @@ func newReportsScheduleCmd() *cobra.Command {
Short: "View upcoming schedule entries",
Long: `View upcoming schedule entries and assignables within a date window.

By default starts from today. Use --start and --end to specify a different range.
Dates can be natural language (e.g., "today", "next week", "+7") or YYYY-MM-DD format.`,
Defaults to a 30-day window starting today. Use --start and --end to specify a
different range. Dates can be natural language (e.g., "today", "next week", "+7")
or YYYY-MM-DD format.`,
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())

if err := ensureAccount(cmd, app); err != nil {
return err
}

// Parse dates if provided (dateparse handles natural language like "today", "+7")
// Unrecognized formats are normalized (trimmed/lowercased) and passed through for the API to validate
// Default start to today when omitted (API requires at least a start date)
if startDate == "" {
startDate = "today"
}
parsedStart := dateparse.Parse(startDate)
parsedEnd := dateparse.Parse(endDate)
// The API requires both window_starts_on and window_ends_on (params.require
// in DateParams concern; missing either returns HTTP 400). Apply defaults so
// the bare `basecamp reports schedule` invocation works out of the box.
// When only --start is provided, anchor the default end 30 days after the
// resolved start date rather than 30 days after today.
parsedStart, parsedEnd := resolveReportsScheduleWindow(startDate, endDate, time.Now())

result, err := app.Account().Reports().UpcomingSchedule(cmd.Context(), parsedStart, parsedEnd)
if err != nil {
Expand Down Expand Up @@ -333,8 +349,8 @@ Dates can be natural language (e.g., "today", "next week", "+7") or YYYY-MM-DD f
},
}

cmd.Flags().StringVar(&startDate, "start", "", "Start date (e.g., today, next week, 2024-01-15)")
cmd.Flags().StringVar(&endDate, "end", "", "End date (e.g., +30, eom, 2024-02-15)")
cmd.Flags().StringVar(&startDate, "start", "", `Start of window (default: today; e.g., "next week", "2024-01-15")`)
cmd.Flags().StringVar(&endDate, "end", "", `End of window (default: +30; e.g., "+30", "eom", "2024-02-15")`)

return cmd
}
53 changes: 53 additions & 0 deletions internal/commands/reports_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package commands

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestResolveReportsScheduleWindow(t *testing.T) {
now := time.Date(2026, time.March, 25, 9, 30, 0, 0, time.UTC)

tests := []struct {
name string
startDate string
endDate string
wantStart string
wantEnd string
}{
{
name: "defaults to today plus 30 days",
wantStart: "2026-03-25",
wantEnd: "2026-04-24",
},
{
name: "default end is anchored to resolved start",
startDate: "next month",
wantStart: "2026-04-25",
wantEnd: "2026-05-25",
},
{
name: "explicit absolute start gets 30 day default end",
startDate: "2026-04-10",
wantStart: "2026-04-10",
wantEnd: "2026-05-10",
},
{
name: "explicit end is preserved",
startDate: "next month",
endDate: "2026-05-01",
wantStart: "2026-04-25",
wantEnd: "2026-05-01",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotStart, gotEnd := resolveReportsScheduleWindow(tt.startDate, tt.endDate, now)
assert.Equal(t, tt.wantStart, gotStart)
assert.Equal(t, tt.wantEnd, gotEnd)
})
}
}
Loading