|
1 | 1 | package api |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bufio" |
| 5 | + "encoding/json" |
| 6 | + "strings" |
4 | 7 | "testing" |
| 8 | + "time" |
5 | 9 |
|
| 10 | + "github.com/onkernel/hypeman/lib/images" |
6 | 11 | "github.com/onkernel/hypeman/lib/oapi" |
7 | 12 | "github.com/stretchr/testify/assert" |
8 | 13 | "github.com/stretchr/testify/require" |
@@ -33,3 +38,117 @@ func TestGetImage_NotFound(t *testing.T) { |
33 | 38 | assert.Equal(t, "image not found", notFound.Message) |
34 | 39 | } |
35 | 40 |
|
| 41 | +func TestCreateImage_AsyncWithSSE(t *testing.T) { |
| 42 | + svc := newTestService(t) |
| 43 | + ctx := ctx() |
| 44 | + |
| 45 | + // 1. Create image (should return 202 Accepted immediately) |
| 46 | + createResp, err := svc.CreateImage(ctx, oapi.CreateImageRequestObject{ |
| 47 | + Body: &oapi.CreateImageRequest{ |
| 48 | + Name: "docker.io/library/alpine:latest", |
| 49 | + }, |
| 50 | + }) |
| 51 | + require.NoError(t, err) |
| 52 | + |
| 53 | + acceptedResp, ok := createResp.(oapi.CreateImage202JSONResponse) |
| 54 | + require.True(t, ok, "expected 202 accepted response") |
| 55 | + |
| 56 | + img := oapi.Image(acceptedResp) |
| 57 | + require.Equal(t, "docker.io/library/alpine:latest", img.Name) |
| 58 | + require.Equal(t, "img-alpine-latest", img.Id) |
| 59 | + require.Contains(t, []oapi.ImageStatus{images.StatusPending, images.StatusPulling}, img.Status) |
| 60 | + require.Equal(t, 0, img.Progress) |
| 61 | + |
| 62 | + // 2. Stream progress via SSE |
| 63 | + progressResp, err := svc.GetImageProgress(ctx, oapi.GetImageProgressRequestObject{ |
| 64 | + Id: img.Id, |
| 65 | + }) |
| 66 | + require.NoError(t, err) |
| 67 | + |
| 68 | + sseResp, ok := progressResp.(oapi.GetImageProgress200TexteventStreamResponse) |
| 69 | + if !ok { |
| 70 | + t.Fatalf("expected SSE stream, got %T", progressResp) |
| 71 | + } |
| 72 | + |
| 73 | + // Read SSE events |
| 74 | + scanner := bufio.NewScanner(sseResp.Body) |
| 75 | + lastProgress := 0 |
| 76 | + sawPulling := false |
| 77 | + sawUnpacking := false |
| 78 | + sawConverting := false |
| 79 | + |
| 80 | + timeout := time.After(3 * time.Minute) |
| 81 | + done := make(chan bool) |
| 82 | + |
| 83 | + go func() { |
| 84 | + for scanner.Scan() { |
| 85 | + line := scanner.Text() |
| 86 | + if !strings.HasPrefix(line, "data: ") { |
| 87 | + continue |
| 88 | + } |
| 89 | + |
| 90 | + data := strings.TrimPrefix(line, "data: ") |
| 91 | + var update images.ProgressUpdate |
| 92 | + if err := json.Unmarshal([]byte(data), &update); err != nil { |
| 93 | + continue |
| 94 | + } |
| 95 | + |
| 96 | + t.Logf("SSE: status=%s, progress=%d%%", update.Status, update.Progress) |
| 97 | + |
| 98 | + // Track which phases we see |
| 99 | + if update.Status == images.StatusPulling { |
| 100 | + sawPulling = true |
| 101 | + } |
| 102 | + if update.Status == images.StatusUnpacking { |
| 103 | + sawUnpacking = true |
| 104 | + } |
| 105 | + if update.Status == images.StatusConverting { |
| 106 | + sawConverting = true |
| 107 | + } |
| 108 | + |
| 109 | + // Progress should be monotonic |
| 110 | + require.GreaterOrEqual(t, update.Progress, lastProgress) |
| 111 | + lastProgress = update.Progress |
| 112 | + |
| 113 | + // Stop when ready |
| 114 | + if update.Status == images.StatusReady { |
| 115 | + require.Equal(t, 100, update.Progress) |
| 116 | + done <- true |
| 117 | + return |
| 118 | + } |
| 119 | + |
| 120 | + // Fail on error |
| 121 | + if update.Status == images.StatusFailed { |
| 122 | + t.Fatalf("Build failed: %v", update.Error) |
| 123 | + } |
| 124 | + } |
| 125 | + }() |
| 126 | + |
| 127 | + // Wait for completion or timeout |
| 128 | + select { |
| 129 | + case <-done: |
| 130 | + // Success |
| 131 | + case <-timeout: |
| 132 | + t.Fatal("Build did not complete within 3 minutes") |
| 133 | + } |
| 134 | + |
| 135 | + // Verify we saw at least one intermediate phase (build might be too fast to catch all) |
| 136 | + sawAnyPhase := sawPulling || sawUnpacking || sawConverting |
| 137 | + require.True(t, sawAnyPhase || lastProgress == 100, "should see at least one build phase or final state") |
| 138 | + |
| 139 | + // 3. Verify final image state |
| 140 | + getResp, err := svc.GetImage(ctx, oapi.GetImageRequestObject{Id: img.Id}) |
| 141 | + require.NoError(t, err) |
| 142 | + |
| 143 | + imgResp, ok := getResp.(oapi.GetImage200JSONResponse) |
| 144 | + require.True(t, ok, "expected 200 response") |
| 145 | + |
| 146 | + finalImg := oapi.Image(imgResp) |
| 147 | + require.Equal(t, oapi.ImageStatus(images.StatusReady), finalImg.Status) |
| 148 | + require.Equal(t, 100, finalImg.Progress) |
| 149 | + require.NotNil(t, finalImg.SizeBytes) |
| 150 | + require.Greater(t, *finalImg.SizeBytes, int64(0)) |
| 151 | + require.Nil(t, finalImg.QueuePosition) |
| 152 | + require.Nil(t, finalImg.Error) |
| 153 | +} |
| 154 | + |
0 commit comments