fix(classroom): add tests, nil guards, and topic filter
- Add comprehensive unit tests for classroom helper functions (date/time parsing, error wrapping, profile helpers) - Add profileEmail() helper for nil-safe email extraction - Fix nil pointer guards for Profile access (12 locations in courses, rosters, and guardians) - Add --topic filter for coursework and materials list commands - Update docs/spec.md with new filter options Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9b96009d91
commit
5e55964a85
@ -185,13 +185,13 @@ Flag aliases:
|
||||
- `gog classroom teachers add <courseId> <userId>`
|
||||
- `gog classroom teachers remove <courseId> <userId>`
|
||||
- `gog classroom roster <courseId> [--students] [--teachers]`
|
||||
- `gog classroom coursework <courseId> [--state ...] [--max N] [--page TOKEN]`
|
||||
- `gog classroom coursework <courseId> [--state ...] [--topic TOPIC_ID] [--max N] [--page TOKEN]`
|
||||
- `gog classroom coursework get <courseId> <courseworkId>`
|
||||
- `gog classroom coursework create <courseId> --title TITLE [--type ASSIGNMENT|...]`
|
||||
- `gog classroom coursework update <courseId> <courseworkId> [--title ...]`
|
||||
- `gog classroom coursework delete <courseId> <courseworkId>`
|
||||
- `gog classroom coursework assignees <courseId> <courseworkId> [--mode ...] [--add-student ...]`
|
||||
- `gog classroom materials <courseId> [--state ...] [--max N] [--page TOKEN]`
|
||||
- `gog classroom materials <courseId> [--state ...] [--topic TOPIC_ID] [--max N] [--page TOKEN]`
|
||||
- `gog classroom materials get <courseId> <materialId>`
|
||||
- `gog classroom materials create <courseId> --title TITLE`
|
||||
- `gog classroom materials update <courseId> <materialId> [--title ...]`
|
||||
|
||||
@ -423,7 +423,7 @@ func (c *ClassroomCoursesJoinCmd) Run(ctx context.Context, flags *RootFlags) err
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"student": created})
|
||||
}
|
||||
u.Out().Printf("user_id\t%s", created.UserId)
|
||||
u.Out().Printf("email\t%s", created.Profile.EmailAddress)
|
||||
u.Out().Printf("email\t%s", profileEmail(created.Profile))
|
||||
u.Out().Printf("name\t%s", profileName(created.Profile))
|
||||
return nil
|
||||
case "teacher":
|
||||
@ -436,7 +436,7 @@ func (c *ClassroomCoursesJoinCmd) Run(ctx context.Context, flags *RootFlags) err
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"teacher": created})
|
||||
}
|
||||
u.Out().Printf("user_id\t%s", created.UserId)
|
||||
u.Out().Printf("email\t%s", created.Profile.EmailAddress)
|
||||
u.Out().Printf("email\t%s", profileEmail(created.Profile))
|
||||
u.Out().Printf("name\t%s", profileName(created.Profile))
|
||||
return nil
|
||||
default:
|
||||
|
||||
@ -24,6 +24,7 @@ type ClassroomCourseworkCmd struct {
|
||||
type ClassroomCourseworkListCmd struct {
|
||||
CourseID string `arg:"" name:"courseId" help:"Course ID or alias"`
|
||||
States string `name:"state" help:"Coursework states filter (comma-separated: DRAFT,PUBLISHED,DELETED)"`
|
||||
Topic string `name:"topic" help:"Filter by topic ID"`
|
||||
OrderBy string `name:"order-by" help:"Order by (e.g., updateTime desc, dueDate desc)"`
|
||||
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
|
||||
Page string `name:"page" help:"Page token"`
|
||||
@ -62,14 +63,27 @@ func (c *ClassroomCourseworkListCmd) Run(ctx context.Context, flags *RootFlags)
|
||||
return wrapClassroomError(err)
|
||||
}
|
||||
|
||||
// Client-side filter by topic (API doesn't support server-side topic filter)
|
||||
topicFilter := strings.TrimSpace(c.Topic)
|
||||
coursework := resp.CourseWork
|
||||
if topicFilter != "" {
|
||||
filtered := make([]*classroom.CourseWork, 0, len(coursework))
|
||||
for _, work := range coursework {
|
||||
if work != nil && work.TopicId == topicFilter {
|
||||
filtered = append(filtered, work)
|
||||
}
|
||||
}
|
||||
coursework = filtered
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"coursework": resp.CourseWork,
|
||||
"coursework": coursework,
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.CourseWork) == 0 {
|
||||
if len(coursework) == 0 {
|
||||
u.Err().Println("No coursework")
|
||||
return nil
|
||||
}
|
||||
@ -77,7 +91,7 @@ func (c *ClassroomCourseworkListCmd) Run(ctx context.Context, flags *RootFlags)
|
||||
w, flush := tableWriter(ctx)
|
||||
defer flush()
|
||||
fmt.Fprintln(w, "ID\tTITLE\tSTATE\tDUE\tTYPE\tMAX_POINTS")
|
||||
for _, work := range resp.CourseWork {
|
||||
for _, work := range coursework {
|
||||
if work == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -72,7 +72,7 @@ func (c *ClassroomGuardiansListCmd) Run(ctx context.Context, flags *RootFlags) e
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n",
|
||||
sanitizeTab(guardian.GuardianId),
|
||||
sanitizeTab(guardian.GuardianProfile.EmailAddress),
|
||||
sanitizeTab(profileEmail(guardian.GuardianProfile)),
|
||||
sanitizeTab(profileName(guardian.GuardianProfile)),
|
||||
)
|
||||
}
|
||||
@ -116,7 +116,7 @@ func (c *ClassroomGuardiansGetCmd) Run(ctx context.Context, flags *RootFlags) er
|
||||
|
||||
u.Out().Printf("id\t%s", guardian.GuardianId)
|
||||
u.Out().Printf("student_id\t%s", guardian.StudentId)
|
||||
u.Out().Printf("email\t%s", guardian.GuardianProfile.EmailAddress)
|
||||
u.Out().Printf("email\t%s", profileEmail(guardian.GuardianProfile))
|
||||
u.Out().Printf("name\t%s", profileName(guardian.GuardianProfile))
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -140,6 +140,13 @@ func profileName(profile *classroom.UserProfile) string {
|
||||
return strings.TrimSpace(strings.TrimSpace(profile.Name.GivenName + " " + profile.Name.FamilyName))
|
||||
}
|
||||
|
||||
func profileEmail(profile *classroom.UserProfile) string {
|
||||
if profile == nil {
|
||||
return ""
|
||||
}
|
||||
return profile.EmailAddress
|
||||
}
|
||||
|
||||
func formatFloatValue(v float64) string {
|
||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", v), "0"), ".")
|
||||
}
|
||||
|
||||
612
internal/cmd/classroom_helpers_test.go
Normal file
612
internal/cmd/classroom_helpers_test.go
Normal file
@ -0,0 +1,612 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/classroom/v1"
|
||||
)
|
||||
|
||||
func TestWrapClassroomError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantNil bool
|
||||
contains string
|
||||
}{
|
||||
{
|
||||
name: "nil error returns nil",
|
||||
err: nil,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "accessNotConfigured wraps with enable link",
|
||||
err: errors.New("accessNotConfigured: Classroom API has not been used"),
|
||||
contains: "console.developers.google.com",
|
||||
},
|
||||
{
|
||||
name: "Classroom API has not been used wraps with enable link",
|
||||
err: errors.New("Classroom API has not been used in project"),
|
||||
contains: "classroom.googleapis.com",
|
||||
},
|
||||
{
|
||||
name: "insufficientPermissions wraps with re-auth hint",
|
||||
err: errors.New("insufficientPermissions: Request had insufficient auth"),
|
||||
contains: "gog auth add",
|
||||
},
|
||||
{
|
||||
name: "insufficient authentication scopes wraps with re-auth hint",
|
||||
err: errors.New("insufficient authentication scopes"),
|
||||
contains: "--services classroom",
|
||||
},
|
||||
{
|
||||
name: "other errors pass through",
|
||||
err: errors.New("some other error"),
|
||||
contains: "some other error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := wrapClassroomError(tt.err)
|
||||
if tt.wantNil {
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
if tt.contains != "" && !strings.Contains(got.Error(), tt.contains) {
|
||||
t.Errorf("error %q does not contain %q", got.Error(), tt.contains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatClassroomDate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
date *classroom.Date
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil date returns empty",
|
||||
date: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "zero values return empty",
|
||||
date: &classroom.Date{Year: 0, Month: 0, Day: 0},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "partial values return empty",
|
||||
date: &classroom.Date{Year: 2024, Month: 0, Day: 15},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "valid date formats correctly",
|
||||
date: &classroom.Date{Year: 2024, Month: 3, Day: 15},
|
||||
want: "2024-03-15",
|
||||
},
|
||||
{
|
||||
name: "single digit month and day get padded",
|
||||
date: &classroom.Date{Year: 2024, Month: 1, Day: 5},
|
||||
want: "2024-01-05",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatClassroomDate(tt.date)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatClassroomDate() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatClassroomTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
time *classroom.TimeOfDay
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil time returns empty",
|
||||
time: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "hours and minutes only",
|
||||
time: &classroom.TimeOfDay{Hours: 14, Minutes: 30},
|
||||
want: "14:30",
|
||||
},
|
||||
{
|
||||
name: "with seconds",
|
||||
time: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 45},
|
||||
want: "14:30:45",
|
||||
},
|
||||
{
|
||||
name: "with nanos (shows seconds)",
|
||||
time: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Nanos: 1},
|
||||
want: "14:30:00",
|
||||
},
|
||||
{
|
||||
name: "single digit hours and minutes get padded",
|
||||
time: &classroom.TimeOfDay{Hours: 9, Minutes: 5},
|
||||
want: "09:05",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatClassroomTime(tt.time)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatClassroomTime() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatClassroomDue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
date *classroom.Date
|
||||
time *classroom.TimeOfDay
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil date and time returns empty",
|
||||
date: nil,
|
||||
time: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "date only",
|
||||
date: &classroom.Date{Year: 2024, Month: 3, Day: 15},
|
||||
time: nil,
|
||||
want: "2024-03-15",
|
||||
},
|
||||
{
|
||||
name: "time only",
|
||||
date: nil,
|
||||
time: &classroom.TimeOfDay{Hours: 14, Minutes: 30},
|
||||
want: "14:30",
|
||||
},
|
||||
{
|
||||
name: "date and time",
|
||||
date: &classroom.Date{Year: 2024, Month: 3, Day: 15},
|
||||
time: &classroom.TimeOfDay{Hours: 14, Minutes: 30},
|
||||
want: "2024-03-15 14:30",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatClassroomDue(tt.date, tt.time)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatClassroomDue() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClassroomDate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
want *classroom.Date
|
||||
}{
|
||||
{
|
||||
name: "empty value errors",
|
||||
value: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "whitespace only errors",
|
||||
value: " ",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format errors",
|
||||
value: "2024/03/15",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid date parses correctly",
|
||||
value: "2024-03-15",
|
||||
wantErr: false,
|
||||
want: &classroom.Date{Year: 2024, Month: 3, Day: 15},
|
||||
},
|
||||
{
|
||||
name: "whitespace trimmed",
|
||||
value: " 2024-03-15 ",
|
||||
wantErr: false,
|
||||
want: &classroom.Date{Year: 2024, Month: 3, Day: 15},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseClassroomDate(tt.value)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Year != tt.want.Year || got.Month != tt.want.Month || got.Day != tt.want.Day {
|
||||
t.Errorf("parseClassroomDate() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClassroomTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
want *classroom.TimeOfDay
|
||||
}{
|
||||
{
|
||||
name: "empty value errors",
|
||||
value: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format errors",
|
||||
value: "14:30:45:00",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "HH:MM format parses",
|
||||
value: "14:30",
|
||||
wantErr: false,
|
||||
want: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 0},
|
||||
},
|
||||
{
|
||||
name: "HH:MM:SS format parses",
|
||||
value: "14:30:45",
|
||||
wantErr: false,
|
||||
want: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 45},
|
||||
},
|
||||
{
|
||||
name: "whitespace trimmed",
|
||||
value: " 14:30 ",
|
||||
wantErr: false,
|
||||
want: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseClassroomTime(tt.value)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Hours != tt.want.Hours || got.Minutes != tt.want.Minutes || got.Seconds != tt.want.Seconds {
|
||||
t.Errorf("parseClassroomTime() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClassroomDue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
wantDate *classroom.Date
|
||||
wantTime *classroom.TimeOfDay
|
||||
}{
|
||||
{
|
||||
name: "empty value returns nil",
|
||||
value: "",
|
||||
wantErr: false,
|
||||
wantDate: nil,
|
||||
wantTime: nil,
|
||||
},
|
||||
{
|
||||
name: "RFC3339 format parses",
|
||||
value: "2024-03-15T14:30:00Z",
|
||||
wantErr: false,
|
||||
wantDate: &classroom.Date{Year: 2024, Month: 3, Day: 15},
|
||||
wantTime: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 0},
|
||||
},
|
||||
{
|
||||
name: "YYYY-MM-DD HH:MM format parses",
|
||||
value: "2024-03-15 14:30",
|
||||
wantErr: false,
|
||||
wantDate: &classroom.Date{Year: 2024, Month: 3, Day: 15},
|
||||
wantTime: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 0},
|
||||
},
|
||||
{
|
||||
name: "YYYY-MM-DDTHH:MM format parses",
|
||||
value: "2024-03-15T14:30",
|
||||
wantErr: false,
|
||||
wantDate: &classroom.Date{Year: 2024, Month: 3, Day: 15},
|
||||
wantTime: &classroom.TimeOfDay{Hours: 14, Minutes: 30, Seconds: 0},
|
||||
},
|
||||
{
|
||||
name: "date only parses",
|
||||
value: "2024-03-15",
|
||||
wantErr: false,
|
||||
wantDate: &classroom.Date{Year: 2024, Month: 3, Day: 15},
|
||||
wantTime: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid format errors",
|
||||
value: "not-a-date",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotDate, gotTime, err := parseClassroomDue(tt.value)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if tt.wantDate == nil {
|
||||
if gotDate != nil {
|
||||
t.Errorf("expected nil date, got %+v", gotDate)
|
||||
}
|
||||
} else {
|
||||
if gotDate == nil {
|
||||
t.Fatal("expected non-nil date")
|
||||
}
|
||||
if gotDate.Year != tt.wantDate.Year || gotDate.Month != tt.wantDate.Month || gotDate.Day != tt.wantDate.Day {
|
||||
t.Errorf("date = %+v, want %+v", gotDate, tt.wantDate)
|
||||
}
|
||||
}
|
||||
|
||||
if tt.wantTime == nil {
|
||||
if gotTime != nil {
|
||||
t.Errorf("expected nil time, got %+v", gotTime)
|
||||
}
|
||||
} else {
|
||||
if gotTime == nil {
|
||||
t.Fatal("expected non-nil time")
|
||||
}
|
||||
if gotTime.Hours != tt.wantTime.Hours || gotTime.Minutes != tt.wantTime.Minutes {
|
||||
t.Errorf("time = %+v, want %+v", gotTime, tt.wantTime)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fields []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty slice returns empty",
|
||||
fields: []string{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "single field",
|
||||
fields: []string{"name"},
|
||||
want: "name",
|
||||
},
|
||||
{
|
||||
name: "multiple fields joined with comma",
|
||||
fields: []string{"name", "description", "state"},
|
||||
want: "name,description,state",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := updateMask(tt.fields)
|
||||
if got != tt.want {
|
||||
t.Errorf("updateMask() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFloat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "empty value errors",
|
||||
value: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format errors",
|
||||
value: "not-a-number",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "integer parses",
|
||||
value: "42",
|
||||
wantErr: false,
|
||||
want: 42.0,
|
||||
},
|
||||
{
|
||||
name: "decimal parses",
|
||||
value: "3.14",
|
||||
wantErr: false,
|
||||
want: 3.14,
|
||||
},
|
||||
{
|
||||
name: "whitespace trimmed",
|
||||
value: " 3.14 ",
|
||||
wantErr: false,
|
||||
want: 3.14,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseFloat(tt.value)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("parseFloat() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profile *classroom.UserProfile
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil profile returns empty",
|
||||
profile: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "nil name returns empty",
|
||||
profile: &classroom.UserProfile{Name: nil},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "full name preferred",
|
||||
profile: &classroom.UserProfile{Name: &classroom.Name{FullName: "John Doe", GivenName: "John", FamilyName: "Doe"}},
|
||||
want: "John Doe",
|
||||
},
|
||||
{
|
||||
name: "falls back to given + family name",
|
||||
profile: &classroom.UserProfile{Name: &classroom.Name{GivenName: "John", FamilyName: "Doe"}},
|
||||
want: "John Doe",
|
||||
},
|
||||
{
|
||||
name: "handles missing family name",
|
||||
profile: &classroom.UserProfile{Name: &classroom.Name{GivenName: "John"}},
|
||||
want: "John",
|
||||
},
|
||||
{
|
||||
name: "handles missing given name",
|
||||
profile: &classroom.UserProfile{Name: &classroom.Name{FamilyName: "Doe"}},
|
||||
want: "Doe",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := profileName(tt.profile)
|
||||
if got != tt.want {
|
||||
t.Errorf("profileName() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profile *classroom.UserProfile
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil profile returns empty",
|
||||
profile: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "returns email address",
|
||||
profile: &classroom.UserProfile{EmailAddress: "test@example.com"},
|
||||
want: "test@example.com",
|
||||
},
|
||||
{
|
||||
name: "empty email returns empty",
|
||||
profile: &classroom.UserProfile{EmailAddress: ""},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := profileEmail(tt.profile)
|
||||
if got != tt.want {
|
||||
t.Errorf("profileEmail() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFloatValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value float64
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "integer value",
|
||||
value: 100.0,
|
||||
want: "100",
|
||||
},
|
||||
{
|
||||
name: "single decimal place",
|
||||
value: 85.5,
|
||||
want: "85.5",
|
||||
},
|
||||
{
|
||||
name: "two decimal places",
|
||||
value: 85.75,
|
||||
want: "85.75",
|
||||
},
|
||||
{
|
||||
name: "trailing zeros removed",
|
||||
value: 85.10,
|
||||
want: "85.1",
|
||||
},
|
||||
{
|
||||
name: "zero",
|
||||
value: 0.0,
|
||||
want: "0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatFloatValue(tt.value)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatFloatValue() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@ type ClassroomMaterialsCmd struct {
|
||||
type ClassroomMaterialsListCmd struct {
|
||||
CourseID string `arg:"" name:"courseId" help:"Course ID or alias"`
|
||||
States string `name:"state" help:"Material states filter (comma-separated: PUBLISHED,DRAFT,DELETED)"`
|
||||
Topic string `name:"topic" help:"Filter by topic ID"`
|
||||
OrderBy string `name:"order-by" help:"Order by (e.g., updateTime desc)"`
|
||||
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
|
||||
Page string `name:"page" help:"Page token"`
|
||||
@ -61,14 +62,27 @@ func (c *ClassroomMaterialsListCmd) Run(ctx context.Context, flags *RootFlags) e
|
||||
return wrapClassroomError(err)
|
||||
}
|
||||
|
||||
// Client-side filter by topic (API doesn't support server-side topic filter)
|
||||
topicFilter := strings.TrimSpace(c.Topic)
|
||||
materials := resp.CourseWorkMaterial
|
||||
if topicFilter != "" {
|
||||
filtered := make([]*classroom.CourseWorkMaterial, 0, len(materials))
|
||||
for _, material := range materials {
|
||||
if material != nil && material.TopicId == topicFilter {
|
||||
filtered = append(filtered, material)
|
||||
}
|
||||
}
|
||||
materials = filtered
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"materials": resp.CourseWorkMaterial,
|
||||
"materials": materials,
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.CourseWorkMaterial) == 0 {
|
||||
if len(materials) == 0 {
|
||||
u.Err().Println("No materials")
|
||||
return nil
|
||||
}
|
||||
@ -76,7 +90,7 @@ func (c *ClassroomMaterialsListCmd) Run(ctx context.Context, flags *RootFlags) e
|
||||
w, flush := tableWriter(ctx)
|
||||
defer flush()
|
||||
fmt.Fprintln(w, "ID\tTITLE\tSTATE\tUPDATED")
|
||||
for _, material := range resp.CourseWorkMaterial {
|
||||
for _, material := range materials {
|
||||
if material == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -67,7 +67,7 @@ func (c *ClassroomStudentsListCmd) Run(ctx context.Context, flags *RootFlags) er
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n",
|
||||
sanitizeTab(student.UserId),
|
||||
sanitizeTab(student.Profile.EmailAddress),
|
||||
sanitizeTab(profileEmail(student.Profile)),
|
||||
sanitizeTab(profileName(student.Profile)),
|
||||
)
|
||||
}
|
||||
@ -110,7 +110,7 @@ func (c *ClassroomStudentsGetCmd) Run(ctx context.Context, flags *RootFlags) err
|
||||
}
|
||||
|
||||
u.Out().Printf("user_id\t%s", student.UserId)
|
||||
u.Out().Printf("email\t%s", student.Profile.EmailAddress)
|
||||
u.Out().Printf("email\t%s", profileEmail(student.Profile))
|
||||
u.Out().Printf("name\t%s", profileName(student.Profile))
|
||||
if student.StudentWorkFolder != nil {
|
||||
u.Out().Printf("work_folder\t%s", student.StudentWorkFolder.Id)
|
||||
@ -158,7 +158,7 @@ func (c *ClassroomStudentsAddCmd) Run(ctx context.Context, flags *RootFlags) err
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"student": created})
|
||||
}
|
||||
u.Out().Printf("user_id\t%s", created.UserId)
|
||||
u.Out().Printf("email\t%s", created.Profile.EmailAddress)
|
||||
u.Out().Printf("email\t%s", profileEmail(created.Profile))
|
||||
u.Out().Printf("name\t%s", profileName(created.Profile))
|
||||
return nil
|
||||
}
|
||||
@ -265,7 +265,7 @@ func (c *ClassroomTeachersListCmd) Run(ctx context.Context, flags *RootFlags) er
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n",
|
||||
sanitizeTab(teacher.UserId),
|
||||
sanitizeTab(teacher.Profile.EmailAddress),
|
||||
sanitizeTab(profileEmail(teacher.Profile)),
|
||||
sanitizeTab(profileName(teacher.Profile)),
|
||||
)
|
||||
}
|
||||
@ -308,7 +308,7 @@ func (c *ClassroomTeachersGetCmd) Run(ctx context.Context, flags *RootFlags) err
|
||||
}
|
||||
|
||||
u.Out().Printf("user_id\t%s", teacher.UserId)
|
||||
u.Out().Printf("email\t%s", teacher.Profile.EmailAddress)
|
||||
u.Out().Printf("email\t%s", profileEmail(teacher.Profile))
|
||||
u.Out().Printf("name\t%s", profileName(teacher.Profile))
|
||||
return nil
|
||||
}
|
||||
@ -348,7 +348,7 @@ func (c *ClassroomTeachersAddCmd) Run(ctx context.Context, flags *RootFlags) err
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"teacher": created})
|
||||
}
|
||||
u.Out().Printf("user_id\t%s", created.UserId)
|
||||
u.Out().Printf("email\t%s", created.Profile.EmailAddress)
|
||||
u.Out().Printf("email\t%s", profileEmail(created.Profile))
|
||||
u.Out().Printf("name\t%s", profileName(created.Profile))
|
||||
return nil
|
||||
}
|
||||
@ -466,7 +466,7 @@ func (c *ClassroomRosterCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
}
|
||||
fmt.Fprintf(w, "teacher\t%s\t%s\t%s\n",
|
||||
sanitizeTab(teacher.UserId),
|
||||
sanitizeTab(teacher.Profile.EmailAddress),
|
||||
sanitizeTab(profileEmail(teacher.Profile)),
|
||||
sanitizeTab(profileName(teacher.Profile)),
|
||||
)
|
||||
}
|
||||
@ -481,7 +481,7 @@ func (c *ClassroomRosterCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
}
|
||||
fmt.Fprintf(w, "student\t%s\t%s\t%s\n",
|
||||
sanitizeTab(student.UserId),
|
||||
sanitizeTab(student.Profile.EmailAddress),
|
||||
sanitizeTab(profileEmail(student.Profile)),
|
||||
sanitizeTab(profileName(student.Profile)),
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user