Route icon results through UI events (T-607)
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrIconDeliveryInvalid = errors.New("icon event delivery is invalid")
|
||||
ErrIconEventPublish = errors.New("publish icon application event")
|
||||
)
|
||||
|
||||
// IconLoader is the narrow cache contract used by background delivery.
|
||||
type IconLoader interface {
|
||||
Load(context.Context, IconRequest) (IconResult, error)
|
||||
}
|
||||
|
||||
// IconLoaderFunc adapts a function to IconLoader.
|
||||
type IconLoaderFunc func(context.Context, IconRequest) (IconResult, error)
|
||||
|
||||
func (function IconLoaderFunc) Load(
|
||||
ctx context.Context,
|
||||
request IconRequest,
|
||||
) (IconResult, error) {
|
||||
return function(ctx, request)
|
||||
}
|
||||
|
||||
// IconEventPublisher queues application events for UI adapters.
|
||||
type IconEventPublisher interface {
|
||||
Publish(context.Context, application.Event) error
|
||||
}
|
||||
|
||||
// IconEventPublisherFunc adapts a function to IconEventPublisher.
|
||||
type IconEventPublisherFunc func(context.Context, application.Event) error
|
||||
|
||||
func (function IconEventPublisherFunc) Publish(
|
||||
ctx context.Context,
|
||||
event application.Event,
|
||||
) error {
|
||||
return function(ctx, event)
|
||||
}
|
||||
|
||||
// IconEventDelivery loads and decodes an icon in a caller-owned background task.
|
||||
// It never creates goroutines and never imports or mutates Gio state.
|
||||
type IconEventDelivery struct {
|
||||
Loader IconLoader
|
||||
Publisher IconEventPublisher
|
||||
}
|
||||
|
||||
// LoadAndPublish emits one ready or failed application event.
|
||||
// Cancellation ends silently so an obsolete request cannot publish a stale failure.
|
||||
func (delivery IconEventDelivery) LoadAndPublish(
|
||||
ctx context.Context,
|
||||
identity application.IconEventIdentity,
|
||||
) error {
|
||||
validated, err := application.NewIconEventIdentity(
|
||||
identity.RequestID,
|
||||
identity.AppID,
|
||||
identity.Reference,
|
||||
identity.DPI,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrIconDeliveryInvalid, err)
|
||||
}
|
||||
if isNilIconDeliveryDependency(delivery.Loader) ||
|
||||
isNilIconDeliveryDependency(delivery.Publisher) {
|
||||
return fmt.Errorf(
|
||||
"%w: loader and publisher are required",
|
||||
ErrIconDeliveryInvalid,
|
||||
)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, loadErr := delivery.Loader.Load(ctx, IconRequest{
|
||||
Reference: validated.Reference,
|
||||
DPI: validated.DPI,
|
||||
})
|
||||
if loadErr != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
if isIconDeliveryCancellation(loadErr) {
|
||||
return loadErr
|
||||
}
|
||||
return delivery.publishFailure(ctx, validated, loadErr)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
icon, decodeErr := DecodeIcon(result.Bytes)
|
||||
if decodeErr != nil {
|
||||
return delivery.publishFailure(ctx, validated, decodeErr)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
ready, eventErr := application.NewIconReadyEvent(validated, icon)
|
||||
if eventErr != nil {
|
||||
return fmt.Errorf("%w: %v", ErrIconDeliveryInvalid, eventErr)
|
||||
}
|
||||
if publishErr := delivery.Publisher.Publish(ctx, ready); publishErr != nil {
|
||||
if isIconDeliveryCancellation(publishErr) {
|
||||
return publishErr
|
||||
}
|
||||
return errors.Join(ErrIconEventPublish, publishErr)
|
||||
}
|
||||
return result.Warning
|
||||
}
|
||||
|
||||
func (delivery IconEventDelivery) publishFailure(
|
||||
ctx context.Context,
|
||||
identity application.IconEventIdentity,
|
||||
cause error,
|
||||
) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
failed, eventErr := application.NewIconFailedEvent(
|
||||
identity,
|
||||
classifyIconFailure(cause),
|
||||
)
|
||||
if eventErr != nil {
|
||||
return errors.Join(
|
||||
fmt.Errorf("load or decode icon: %w", cause),
|
||||
fmt.Errorf("%w: %v", ErrIconDeliveryInvalid, eventErr),
|
||||
)
|
||||
}
|
||||
publishErr := delivery.Publisher.Publish(ctx, failed)
|
||||
operationErr := fmt.Errorf("load or decode icon: %w", cause)
|
||||
if publishErr != nil {
|
||||
if isIconDeliveryCancellation(publishErr) {
|
||||
return publishErr
|
||||
}
|
||||
return errors.Join(operationErr, ErrIconEventPublish, publishErr)
|
||||
}
|
||||
return operationErr
|
||||
}
|
||||
|
||||
func classifyIconFailure(err error) application.IconFailureCode {
|
||||
switch {
|
||||
case errors.Is(err, ErrIconCacheUnsafe):
|
||||
return application.IconFailureUnsafe
|
||||
case errors.Is(err, ErrIconReferenceInvalid),
|
||||
errors.Is(err, ErrIconDPIInvalid),
|
||||
errors.Is(err, ErrIconHashMismatch),
|
||||
errors.Is(err, ErrIconTooLarge),
|
||||
errors.Is(err, ErrIconImageInvalid),
|
||||
errors.Is(err, ErrIconResponseInvalid):
|
||||
return application.IconFailureInvalid
|
||||
default:
|
||||
return application.IconFailureUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func isIconDeliveryCancellation(err error) bool {
|
||||
return errors.Is(err, context.Canceled) ||
|
||||
errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
func isNilIconDeliveryDependency(dependency any) bool {
|
||||
if dependency == nil {
|
||||
return true
|
||||
}
|
||||
value := reflect.ValueOf(dependency)
|
||||
switch value.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map,
|
||||
reflect.Ptr, reflect.Slice:
|
||||
return value.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestIconEventDeliveryPublishesDecodedReadyEvent(t *testing.T) {
|
||||
document := testPNG(t, 24, 24)
|
||||
identity := iconEventIdentity(t, document, "request-ready")
|
||||
var gotRequest IconRequest
|
||||
var events []application.Event
|
||||
delivery := IconEventDelivery{
|
||||
Loader: IconLoaderFunc(func(
|
||||
_ context.Context,
|
||||
request IconRequest,
|
||||
) (IconResult, error) {
|
||||
gotRequest = request
|
||||
return IconResult{Bytes: document, Source: IconSourceMemory}, nil
|
||||
}),
|
||||
Publisher: IconEventPublisherFunc(func(
|
||||
_ context.Context,
|
||||
event application.Event,
|
||||
) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
if err := delivery.LoadAndPublish(context.Background(), identity); err != nil {
|
||||
t.Fatalf("LoadAndPublish() error = %v", err)
|
||||
}
|
||||
if gotRequest.Reference != identity.Reference || gotRequest.DPI != identity.DPI {
|
||||
t.Fatalf("loader request = %+v", gotRequest)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("published events = %d", len(events))
|
||||
}
|
||||
parsed, handled, err := application.ParseIconEvent(events[0])
|
||||
if err != nil || !handled {
|
||||
t.Fatalf("ParseIconEvent() = (%+v, %t, %v)", parsed, handled, err)
|
||||
}
|
||||
if parsed.Type != application.EventIconReady || parsed.Identity != identity {
|
||||
t.Fatalf("ready event = %+v", parsed)
|
||||
}
|
||||
if bounds := parsed.Image.Bounds(); bounds.Dx() != 24 || bounds.Dy() != 24 {
|
||||
t.Fatalf("decoded bounds = %v", bounds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconEventDeliveryClassifiesFailuresWithoutRawErrorPayload(t *testing.T) {
|
||||
document := testPNG(t, 8, 8)
|
||||
identity := iconEventIdentity(t, document, "request-failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
loadErr error
|
||||
bytes []byte
|
||||
wantCode application.IconFailureCode
|
||||
}{
|
||||
{name: "unsafe", loadErr: ErrIconCacheUnsafe, wantCode: application.IconFailureUnsafe},
|
||||
{name: "invalid", loadErr: ErrIconHashMismatch, wantCode: application.IconFailureInvalid},
|
||||
{
|
||||
name: "unavailable",
|
||||
loadErr: errors.New("GET https://secret.invalid/icon?token=hidden failed"),
|
||||
wantCode: application.IconFailureUnavailable,
|
||||
},
|
||||
{name: "decode", bytes: []byte("not an image"), wantCode: application.IconFailureInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var published application.Event
|
||||
delivery := IconEventDelivery{
|
||||
Loader: IconLoaderFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconResult, error) {
|
||||
return IconResult{Bytes: test.bytes}, test.loadErr
|
||||
}),
|
||||
Publisher: IconEventPublisherFunc(func(
|
||||
_ context.Context,
|
||||
event application.Event,
|
||||
) error {
|
||||
published = event
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
err := delivery.LoadAndPublish(context.Background(), identity)
|
||||
if err == nil {
|
||||
t.Fatal("LoadAndPublish() unexpectedly succeeded")
|
||||
}
|
||||
parsed, handled, parseErr := application.ParseIconEvent(published)
|
||||
if parseErr != nil || !handled {
|
||||
t.Fatalf("ParseIconEvent() = (%+v, %t, %v)", parsed, handled, parseErr)
|
||||
}
|
||||
if parsed.Type != application.EventIconFailed || parsed.ErrorCode != test.wantCode {
|
||||
t.Fatalf("failed event = %+v", parsed)
|
||||
}
|
||||
payload := published.Payload.(application.IconFailedPayload)
|
||||
if strings.Contains(string(payload.ErrorCode), "secret") ||
|
||||
strings.Contains(string(payload.ErrorCode), "token") {
|
||||
t.Fatalf("failure payload leaked raw error: %+v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconEventDeliveryReportsPublishFailureAndCacheWarning(t *testing.T) {
|
||||
document := testPNG(t, 12, 12)
|
||||
identity := iconEventIdentity(t, document, "request-publish")
|
||||
publishErr := errors.New("event queue closed")
|
||||
delivery := IconEventDelivery{
|
||||
Loader: IconLoaderFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconResult, error) {
|
||||
return IconResult{Bytes: document}, nil
|
||||
}),
|
||||
Publisher: IconEventPublisherFunc(func(
|
||||
context.Context,
|
||||
application.Event,
|
||||
) error {
|
||||
return publishErr
|
||||
}),
|
||||
}
|
||||
if err := delivery.LoadAndPublish(context.Background(), identity); !errors.Is(err, ErrIconEventPublish) || !errors.Is(err, publishErr) {
|
||||
t.Fatalf("LoadAndPublish() error = %v", err)
|
||||
}
|
||||
|
||||
warning := errors.New("disk store warning")
|
||||
delivery.Loader = IconLoaderFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconResult, error) {
|
||||
return IconResult{Bytes: document, Warning: warning}, nil
|
||||
})
|
||||
delivery.Publisher = IconEventPublisherFunc(func(
|
||||
context.Context,
|
||||
application.Event,
|
||||
) error {
|
||||
return nil
|
||||
})
|
||||
if err := delivery.LoadAndPublish(context.Background(), identity); !errors.Is(err, warning) {
|
||||
t.Fatalf("LoadAndPublish() warning = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconEventDeliveryCancellationPublishesNothing(t *testing.T) {
|
||||
document := testPNG(t, 10, 10)
|
||||
identity := iconEventIdentity(t, document, "request-canceled")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
loaderCalled := false
|
||||
publisherCalled := false
|
||||
delivery := IconEventDelivery{
|
||||
Loader: IconLoaderFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconResult, error) {
|
||||
loaderCalled = true
|
||||
return IconResult{}, nil
|
||||
}),
|
||||
Publisher: IconEventPublisherFunc(func(
|
||||
context.Context,
|
||||
application.Event,
|
||||
) error {
|
||||
publisherCalled = true
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
if err := delivery.LoadAndPublish(ctx, identity); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("LoadAndPublish() error = %v", err)
|
||||
}
|
||||
if loaderCalled || publisherCalled {
|
||||
t.Fatalf("canceled delivery called loader=%t publisher=%t", loaderCalled, publisherCalled)
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
loaderCalled = false
|
||||
publisherCalled = false
|
||||
delivery.Loader = IconLoaderFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconResult, error) {
|
||||
loaderCalled = true
|
||||
cancel()
|
||||
return IconResult{Bytes: document}, nil
|
||||
})
|
||||
if err := delivery.LoadAndPublish(ctx, identity); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("LoadAndPublish(cancel during load) error = %v", err)
|
||||
}
|
||||
if !loaderCalled || publisherCalled {
|
||||
t.Fatalf(
|
||||
"cancel-during-load called loader=%t publisher=%t",
|
||||
loaderCalled,
|
||||
publisherCalled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconEventDeliveryRejectsIncompleteDependencies(t *testing.T) {
|
||||
document := testPNG(t, 4, 4)
|
||||
identity := iconEventIdentity(t, document, "request-invalid")
|
||||
tests := []IconEventDelivery{
|
||||
{},
|
||||
{Loader: IconLoaderFunc(nil), Publisher: IconEventPublisherFunc(nil)},
|
||||
}
|
||||
for _, delivery := range tests {
|
||||
if err := delivery.LoadAndPublish(context.Background(), identity); !errors.Is(err, ErrIconDeliveryInvalid) {
|
||||
t.Fatalf("LoadAndPublish() error = %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func iconEventIdentity(
|
||||
t *testing.T,
|
||||
document []byte,
|
||||
requestID string,
|
||||
) application.IconEventIdentity {
|
||||
t.Helper()
|
||||
request := iconRequest(document, 96)
|
||||
identity, err := application.NewIconEventIdentity(
|
||||
requestID,
|
||||
"app-one",
|
||||
request.Reference,
|
||||
request.DPI,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
Reference in New Issue
Block a user