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
4 changes: 3 additions & 1 deletion api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import (
"time"
)

const graphqlEndpoint = "https://developer.api.autodesk.com/mfg/graphql"
// graphqlEndpoint is a var (not const) so tests can point it at an
// httptest.Server. Production code never reassigns it.
var graphqlEndpoint = "https://developer.api.autodesk.com/mfg/graphql"

// region is the X-Ads-Region header value sent with every request.
// Empty means no header is sent (defaults to US on the server side).
Expand Down
146 changes: 145 additions & 1 deletion api/client_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
package api

import "testing"
import (
"context"
"encoding/json"
"strings"
"sync/atomic"
"testing"

"github.com/schneik80/FusionDataCLI/internal/testutil"
)

func TestSetRegion(t *testing.T) {
orig := region
Expand All @@ -26,3 +34,139 @@ func TestSetRegion(t *testing.T) {
})
}
}

// swapEndpoint redirects the package-level graphqlEndpoint to url and
// schedules restoration via t.Cleanup. Tests use this to point the GraphQL
// client at an httptest.Server.
func swapEndpoint(t *testing.T, url string) {
t.Helper()
prev := graphqlEndpoint
t.Cleanup(func() { graphqlEndpoint = prev })
graphqlEndpoint = url
}

func TestGqlQuery_HappyPath(t *testing.T) {
var sawAuth, sawQuery, sawFoo bool
srv := testutil.GraphQLServer(t, func(req testutil.GraphQLRequest) testutil.GraphQLResponse {
if req.AuthHeader == "Bearer test-token" {
sawAuth = true
} else {
t.Errorf("AuthHeader = %q, want %q", req.AuthHeader, "Bearer test-token")
}
if strings.Contains(req.Query, "Marker") {
sawQuery = true
} else {
t.Errorf("Query missing marker: %q", req.Query)
}
if v, ok := req.Variables["foo"].(string); ok && v == "bar" {
sawFoo = true
} else {
t.Errorf("Variables[foo] = %v, want \"bar\"", req.Variables["foo"])
}
return testutil.GraphQLResponse{Data: map[string]any{"x": 1}}
})
swapEndpoint(t, srv.URL)

ctx := context.Background()
raw, err := gqlQuery(ctx, "test-token", "query Marker { hubs { id } }", map[string]any{"foo": "bar"})
if err != nil {
t.Fatalf("gqlQuery returned error: %v", err)
}

var got map[string]int
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("decoding raw data: %v (raw=%s)", err, raw)
}
if got["x"] != 1 {
t.Errorf("decoded data = %v, want {x:1}", got)
}
if !sawAuth || !sawQuery || !sawFoo {
t.Errorf("handler missed an assertion: auth=%v query=%v foo=%v", sawAuth, sawQuery, sawFoo)
}
}

func TestGqlQuery_401_Wraps(t *testing.T) {
srv := testutil.GraphQLServer(t, func(req testutil.GraphQLRequest) testutil.GraphQLResponse {
return testutil.GraphQLResponse{Status: 401}
})
swapEndpoint(t, srv.URL)

_, err := gqlQuery(context.Background(), "tok", "query Q {}", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
t.Errorf("error = %q, want substring \"unauthorized\"", err.Error())
}
}

func TestGqlQuery_GraphQLErrors_Joined(t *testing.T) {
srv := testutil.GraphQLServer(t, func(req testutil.GraphQLRequest) testutil.GraphQLResponse {
return testutil.GraphQLResponse{Errors: []string{"first failure", "second failure"}}
})
swapEndpoint(t, srv.URL)

_, err := gqlQuery(context.Background(), "tok", "query Q {}", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "first failure; second failure") {
t.Errorf("error = %q, want both messages joined by \"; \"", msg)
}
}

func TestGqlQuery_EmptyData_Errors(t *testing.T) {
// A response with no "data" field at all leaves gr.Data as a zero-length
// json.RawMessage, which trips the production code's len(gr.Data) == 0
// guard. (Strings like `""` decode to a 2-byte RawMessage and would
// pass that check.)
srv := testutil.GraphQLServer(t, func(req testutil.GraphQLRequest) testutil.GraphQLResponse {
return testutil.GraphQLResponse{RawBody: `{}`}
})
swapEndpoint(t, srv.URL)

_, err := gqlQuery(context.Background(), "tok", "query Q {}", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(strings.ToLower(err.Error()), "empty") {
t.Errorf("error = %q, want substring \"empty\"", err.Error())
}
}

func TestGqlQuery_RegionHeader(t *testing.T) {
// Region is shared global state — back up & restore around the whole test.
origRegion := region
t.Cleanup(func() { region = origRegion })

cases := []struct {
name string
setRegion string
wantRegion string
}{
{name: "with_region", setRegion: "EMEA", wantRegion: "EMEA"},
{name: "without_region", setRegion: "", wantRegion: ""},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var seen atomic.Value // string
seen.Store("")
srv := testutil.GraphQLServer(t, func(req testutil.GraphQLRequest) testutil.GraphQLResponse {
seen.Store(req.Region)
return testutil.GraphQLResponse{Data: map[string]any{"ok": true}}
})
swapEndpoint(t, srv.URL)

SetRegion(tc.setRegion)

if _, err := gqlQuery(context.Background(), "tok", "query Q {}", nil); err != nil {
t.Fatalf("gqlQuery: %v", err)
}
if got := seen.Load().(string); got != tc.wantRegion {
t.Errorf("X-Ads-Region = %q, want %q", got, tc.wantRegion)
}
})
}
}
161 changes: 161 additions & 0 deletions api/details_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package api

import (
"context"
"encoding/json"
"os"
"testing"
"time"

"github.com/schneik80/FusionDataCLI/internal/testutil"
)

func TestParseTime(t *testing.T) {
Expand Down Expand Up @@ -64,6 +69,162 @@ func TestParseTime(t *testing.T) {
}
}

func TestGetItemDetails_AllFields(t *testing.T) {
raw, err := os.ReadFile("testdata/details_design.json")
if err != nil {
t.Fatalf("reading fixture: %v", err)
}
var data map[string]any
if err := json.Unmarshal(raw, &data); err != nil {
t.Fatalf("unmarshaling fixture: %v", err)
}

var sawHubID, sawItemID bool
srv := testutil.GraphQLServer(t, func(req testutil.GraphQLRequest) testutil.GraphQLResponse {
if v, ok := req.Variables["hubId"].(string); ok && v == "h1" {
sawHubID = true
} else {
t.Errorf("Variables[hubId] = %v, want \"h1\"", req.Variables["hubId"])
}
if v, ok := req.Variables["itemId"].(string); ok && v == "item-1" {
sawItemID = true
} else {
t.Errorf("Variables[itemId] = %v, want \"item-1\"", req.Variables["itemId"])
}
return testutil.GraphQLResponse{Data: data}
})
swapEndpoint(t, srv.URL)

got, err := GetItemDetails(context.Background(), "tok", "h1", "item-1")
if err != nil {
t.Fatalf("GetItemDetails: %v", err)
}
if !sawHubID || !sawItemID {
t.Errorf("handler missed an assertion: hubId=%v itemId=%v", sawHubID, sawItemID)
}

if got.ID != "urn:item:abc" {
t.Errorf("ID = %q, want %q", got.ID, "urn:item:abc")
}
if got.Name != "Widget A" {
t.Errorf("Name = %q, want %q", got.Name, "Widget A")
}
if got.Typename != "DesignItem" {
t.Errorf("Typename = %q, want %q", got.Typename, "DesignItem")
}
if got.Size != "12345678" {
t.Errorf("Size = %q, want %q", got.Size, "12345678")
}
if got.MimeType != "application/vnd.autodesk.fusion360" {
t.Errorf("MimeType = %q, want %q", got.MimeType, "application/vnd.autodesk.fusion360")
}
if got.ExtensionType != "Fusion360" {
t.Errorf("ExtensionType = %q, want %q", got.ExtensionType, "Fusion360")
}
if got.FusionWebURL != "https://fusion.example/widget-a" {
t.Errorf("FusionWebURL = %q, want %q", got.FusionWebURL, "https://fusion.example/widget-a")
}
if got.VersionNumber != 3 {
t.Errorf("VersionNumber = %d, want 3", got.VersionNumber)
}

wantCreated := time.Date(2024, 1, 15, 10, 30, 45, 0, time.UTC)
if !got.CreatedOn.Equal(wantCreated) {
t.Errorf("CreatedOn = %v, want %v", got.CreatedOn, wantCreated)
}
wantModified := time.Date(2024, 2, 20, 14, 0, 0, 0, time.UTC)
if !got.ModifiedOn.Equal(wantModified) {
t.Errorf("ModifiedOn = %v, want %v", got.ModifiedOn, wantModified)
}

if got.CreatedBy != "Ada Lovelace" {
t.Errorf("CreatedBy = %q, want %q", got.CreatedBy, "Ada Lovelace")
}
if got.ModifiedBy != "Grace Hopper" {
t.Errorf("ModifiedBy = %q, want %q", got.ModifiedBy, "Grace Hopper")
}
if got.PartNumber != "WGT-001" {
t.Errorf("PartNumber = %q, want %q", got.PartNumber, "WGT-001")
}
if got.PartDesc != "The widget A" {
t.Errorf("PartDesc = %q, want %q", got.PartDesc, "The widget A")
}
if got.Material != "Aluminum 6061" {
t.Errorf("Material = %q, want %q", got.Material, "Aluminum 6061")
}
if !got.IsMilestone {
t.Errorf("IsMilestone = false, want true")
}
if got.RootComponentVersionID != "urn:cv:xyz" {
t.Errorf("RootComponentVersionID = %q, want %q", got.RootComponentVersionID, "urn:cv:xyz")
}

if len(got.Versions) != 3 {
t.Fatalf("len(Versions) = %d, want 3", len(got.Versions))
}
// Reversed: most-recent first.
if got.Versions[0].Number != 3 {
t.Errorf("Versions[0].Number = %d, want 3", got.Versions[0].Number)
}
if got.Versions[1].Number != 2 {
t.Errorf("Versions[1].Number = %d, want 2", got.Versions[1].Number)
}
if got.Versions[2].Number != 1 {
t.Errorf("Versions[2].Number = %d, want 1", got.Versions[2].Number)
}
if got.Versions[0].Comment != "third edit" {
t.Errorf("Versions[0].Comment = %q, want %q", got.Versions[0].Comment, "third edit")
}
if got.Versions[0].CreatedBy != "Grace Hopper" {
t.Errorf("Versions[0].CreatedBy = %q, want %q", got.Versions[0].CreatedBy, "Grace Hopper")
}
}

func TestGetItemDetails_DrawingItem_NoComponentVersion(t *testing.T) {
data := map[string]any{
"item": map[string]any{
"__typename": "DrawingItem",
"id": "urn:item:dwg",
"name": "Sheet 1",
"size": "0",
"mimeType": "application/dwg",
"extensionType": "DrawingItem",
"createdOn": "2024-03-01T09:00:00Z",
"createdBy": map[string]any{"firstName": "X", "lastName": "Y"},
"lastModifiedOn": "2024-03-02T09:00:00Z",
"lastModifiedBy": map[string]any{"firstName": "X", "lastName": "Y"},
"fusionWebUrl": "https://example/dwg",
"tipVersion": map[string]any{"versionNumber": 1},
},
"itemVersions": map[string]any{"results": []any{}},
}

srv := testutil.GraphQLServer(t, func(req testutil.GraphQLRequest) testutil.GraphQLResponse {
return testutil.GraphQLResponse{Data: data}
})
swapEndpoint(t, srv.URL)

got, err := GetItemDetails(context.Background(), "tok", "h1", "item-dwg")
if err != nil {
t.Fatalf("GetItemDetails: %v", err)
}
if got.Typename != "DrawingItem" {
t.Errorf("Typename = %q, want %q", got.Typename, "DrawingItem")
}
if got.RootComponentVersionID != "" {
t.Errorf("RootComponentVersionID = %q, want empty", got.RootComponentVersionID)
}
if got.PartNumber != "" {
t.Errorf("PartNumber = %q, want empty", got.PartNumber)
}
if got.Material != "" {
t.Errorf("Material = %q, want empty", got.Material)
}
if got.IsMilestone {
t.Errorf("IsMilestone = true, want false")
}
}

func TestApiUser_FullName(t *testing.T) {
cases := []struct {
name string
Expand Down
12 changes: 10 additions & 2 deletions api/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ func DownloadFile(ctx context.Context, url, destPath string) error {
return nil
}

// userHomeDir and nowFunc are package vars so tests can inject a temp
// directory and a fixed time for deterministic StepDownloadPath output.
// Production code uses the stdlib defaults.
var (
userHomeDir = os.UserHomeDir
nowFunc = time.Now
)

// StepDownloadPath returns a sensible local destination for a STEP file
// derived from name. Prefers ~/Downloads, falling back to the OS temp dir
// if the home directory cannot be determined. A timestamp suffix avoids
Expand All @@ -108,8 +116,8 @@ func StepDownloadPath(name string) string {
if safe == "" {
safe = "design"
}
fname := fmt.Sprintf("%s-%s.stp", safe, time.Now().Format("20060102-150405"))
if home, err := os.UserHomeDir(); err == nil && home != "" {
fname := fmt.Sprintf("%s-%s.stp", safe, nowFunc().Format("20060102-150405"))
if home, err := userHomeDir(); err == nil && home != "" {
return filepath.Join(home, "Downloads", fname)
}
return filepath.Join(os.TempDir(), fname)
Expand Down
Loading
Loading