roboflow working, and added integration tests
Some checks failed
golangci-lint / lint (push) Failing after 25s
build / Build (push) Successful in 32s
Run Go Tests / build (push) Failing after 0s
Build and Push Docker Image / Build and push image (push) Successful in 2m33s

This commit is contained in:
2025-05-06 20:43:57 -07:00
parent cb14535c1f
commit 0bae08f39c
12 changed files with 829 additions and 482 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
junk2jive-server
.env
.direnv
coverage.out

View File

@@ -29,7 +29,6 @@ func main() {
if err != nil {
logger.Error("Error starting server %v", err)
}
}()
<-stop

View File

@@ -1,97 +0,0 @@
mode: set
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:19.25,23.2 1 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:25.36,27.16 2 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:27.16,29.3 1 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:30.2,30.11 1 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:33.91,37.45 3 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:37.45,39.3 1 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:41.2,41.54 1 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:41.54,43.3 1 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:45.2,47.16 3 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:50.100,51.46 1 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:51.46,52.72 1 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:52.72,56.24 3 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:56.24,59.5 2 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/limiter/limiter.go:61.4,61.24 1 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:25.13,31.62 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:31.62,32.31 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:32.31,33.27 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:34.22,38.8 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:41.5,41.13 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:45.2,45.25 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:48.66,49.39 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:49.39,50.20 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:50.20,52.4 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:53.3,55.26 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/application.go:55.26,57.4 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:13.69,14.46 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:14.46,15.72 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:15.72,32.47 5 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:32.47,34.5 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:36.4,36.80 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:36.80,38.5 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger/request.go:38.10,40.5 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response/response.go:13.43,16.2 2 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response/response.go:18.38,19.63 1 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response/response.go:19.63,21.3 1 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response/response.go:22.2,22.12 1 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response/response.go:25.76,30.2 4 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response/response.go:32.100,35.2 2 1
gitea.miguelmuniz.com/rogueking/junk2jive-server/cmd/junk2jive/main.go:15.13,26.12 7 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/cmd/junk2jive/main.go:26.12,29.17 3 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/cmd/junk2jive/main.go:29.17,31.4 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/cmd/junk2jive/main.go:35.2,41.45 5 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/cmd/junk2jive/main.go:41.45,43.3 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/cmd/junk2jive/main.go:45.2,45.35 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:38.28,42.2 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:45.65,49.61 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:49.61,53.3 3 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:56.2,56.64 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:56.64,59.3 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:62.2,63.16 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:63.16,66.3 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:68.2,72.16 3 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:72.16,75.3 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:78.2,79.38 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:83.77,90.16 4 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:90.16,92.3 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:95.2,100.16 4 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:100.16,102.3 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:103.2,106.38 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:106.38,109.3 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:112.2,113.69 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:113.69,115.3 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:117.2,117.23 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:121.62,123.50 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:123.50,125.3 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/roboflow/roboflow.go:126.2,126.16 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:22.31,23.48 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:23.48,25.3 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:28.28,37.2 3 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:39.38,41.21 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:41.21,43.3 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:43.8,44.34 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:44.34,47.4 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:48.3,48.51 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:50.2,50.13 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/config/config.go:53.81,62.2 3 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:33.53,35.2 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:37.67,58.19 5 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:58.19,60.6 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:62.5,63.19 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:63.19,65.6 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:67.5,72.19 5 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:72.19,74.6 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:75.5,78.72 3 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:78.72,80.6 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:82.5,82.35 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:82.35,84.6 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/services/openai.go:86.5,86.52 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/router/router.go:28.26,32.2 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/router/router.go:34.44,64.55 13 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/router/router.go:64.55,65.46 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/router/router.go:65.46,68.74 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/router/router.go:68.74,70.5 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/router/router.go:72.4,73.58 2 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/router/router.go:76.2,76.11 1 0
gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/router/router.go:80.39,82.2 1 0

View File

@@ -14,8 +14,6 @@ import (
var AllowedOrigins []string
type Flags struct {
// OllamaKey string
// RoboflowKey int
Port int
}

View File

@@ -0,0 +1,135 @@
package config
import (
"flag"
"os"
"testing"
"gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/router"
)
func TestDeclareFlags(t *testing.T) {
// Save original command line arguments
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test cases
tests := []struct {
name string
args []string
expected *Flags
}{
{
name: "No arguments",
args: []string{"cmd"},
expected: &Flags{Port: 0},
},
{
name: "Port flag provided",
args: []string{"cmd", "-p", "8080"},
expected: &Flags{Port: 8080},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Reset flags for each test
flag.CommandLine = flag.NewFlagSet(tc.args[0], flag.ExitOnError)
// Set the arguments
os.Args = tc.args
// Run the function
got := DeclareFlags()
// Check results
if got.Port != tc.expected.Port {
t.Errorf("DeclareFlags() Port = %v, want %v", got.Port, tc.expected.Port)
}
})
}
}
func TestConfigPort(t *testing.T) {
// Save original env and restore after test
oldPort := os.Getenv("APP_PORT")
defer os.Setenv("APP_PORT", oldPort)
tests := []struct {
name string
flags *Flags
envPort string
expected string
}{
{
name: "Flag port specified",
flags: &Flags{Port: 8080},
envPort: "9000",
expected: ":8080",
},
{
name: "No flag port but env port specified",
flags: &Flags{Port: 0},
envPort: "9000",
expected: ":9000",
},
{
name: "No ports specified - default to 80",
flags: &Flags{Port: 0},
envPort: "",
expected: ":80",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
os.Setenv("APP_PORT", tc.envPort)
got := ConfigPort(tc.flags)
if got != tc.expected {
t.Errorf("ConfigPort() = %v, want %v", got, tc.expected)
}
})
}
}
func TestSetupServer(t *testing.T) {
// Create mock router
r := &router.Router{}
flags := &Flags{Port: 8080}
port := ":8080"
server := SetupServer(port, r, flags)
// Verify server was created with correct attributes
if server == nil {
t.Fatal("SetupServer() returned nil")
}
if server.Addr != port {
t.Errorf("SetupServer() created server with Addr = %v, want %v", server.Addr, port)
}
}
func TestLoadEnv(t *testing.T) {
// Create a temporary .env file for testing
tempEnvFile := "test.env"
content := []byte("TEST_VAR=test_value\n")
err := os.WriteFile(tempEnvFile, content, 0644)
if err != nil {
t.Fatalf("Failed to create test env file: %v", err)
}
defer os.Remove(tempEnvFile)
// Test loading the file (this will fail if it can't load)
// Since LoadEnv calls logger.Fatal on error, we can't easily test the failure case
LoadEnv(tempEnvFile)
// Verify the environment variable was loaded
if os.Getenv("TEST_VAR") != "test_value" {
t.Errorf("LoadEnv() didn't properly load TEST_VAR, got %v, want %v",
os.Getenv("TEST_VAR"), "test_value")
}
}

View File

@@ -1,315 +1,290 @@
package ollama
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response"
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
// Mock HTTP client for testing
type mockHTTPClient struct {
DoFunc func(req *http.Request) (*http.Response, error)
type MockOllama struct {
SendRequestFunc func(userMessage string, fileID string) (string, error)
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
return m.DoFunc(req)
func (m *MockOllama) SendRequest(userMessage string, fileID string) (string, error) {
return m.SendRequestFunc(userMessage, fileID)
}
// Helper function to patch the HTTP client for testing
func patchHTTPClient(client interface{}) func() {
originalClient := http.DefaultClient
http.DefaultClient = client.(*http.Client)
return func() {
http.DefaultClient = originalClient
}
}
// TestNewOllama tests the NewOllama function
func TestNewOllama(t *testing.T) {
// Save original env and restore after test
originalKey := os.Getenv("OLLAMA_API_KEY")
defer os.Setenv("OLLAMA_API_KEY", originalKey)
// Save original environment and restore it after test
origEndpoint := os.Getenv("OLLAMA_API_ENDPOINT")
origKey := os.Getenv("OLLAMA_API_KEY")
defer func() {
os.Setenv("OLLAMA_API_ENDPOINT", origEndpoint)
os.Setenv("OLLAMA_API_KEY", origKey)
}()
testKey := "test-api-key"
os.Setenv("OLLAMA_API_KEY", testKey)
// Test with empty environment
os.Unsetenv("OLLAMA_API_ENDPOINT")
os.Setenv("OLLAMA_API_KEY", "test-key")
ollama := NewOllama()
if ollama == nil {
t.Fatal("Expected NewOllama to return a non-nil value")
}
ollama := NewOllama()
if ollama.OllamaEndpoint != "http://localhost:3000/api/chat/completions" {
t.Errorf("Expected default endpoint, got %s", ollama.OllamaEndpoint)
}
if ollama.OllamaKey != "test-key" {
t.Errorf("Expected API key 'test-key', got %s", ollama.OllamaKey)
}
if ollama.OllamaKey != testKey {
t.Errorf("Expected OllamaKey to be %q, got %q", testKey, ollama.OllamaKey)
}
// Test with custom environment
os.Setenv("OLLAMA_API_ENDPOINT", "https://custom-endpoint.com")
os.Setenv("OLLAMA_API_KEY", "custom-key")
ollama = NewOllama()
if ollama.OllamaEndpoint != "https://custom-endpoint.com" {
t.Errorf("Expected custom endpoint, got %s", ollama.OllamaEndpoint)
}
if ollama.OllamaKey != "custom-key" {
t.Errorf("Expected API key 'custom-key', got %s", ollama.OllamaKey)
}
}
// TestSendRequest tests the SendRequest method
func TestSendRequest(t *testing.T) {
tests := []struct {
name string
userMessage string
fileID string
setupMock func() *mockHTTPClient
expectedResult string
expectError bool
}{
{
name: "Successful request without file",
userMessage: "Hello, world!",
fileID: "",
setupMock: func() *mockHTTPClient {
return &mockHTTPClient{
DoFunc: func(req *http.Request) (*http.Response, error) {
// Verify request has correct content and headers
if req.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type header to be application/json")
}
if req.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("Expected Authorization header to be Bearer test-key")
}
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify headers
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type header to be application/json")
}
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("Expected Authorization header to be 'Bearer test-key'")
}
// Check request body
body, _ := io.ReadAll(req.Body)
var reqBody ChatCompletionRequest
if err := json.Unmarshal(body, &reqBody); err != nil {
t.Errorf("Failed to unmarshal request body: %v", err)
}
if reqBody.Model != "gemma3:12b" {
t.Errorf("Expected model to be gemma3:12b, got %s", reqBody.Model)
}
if len(reqBody.Messages) != 1 || reqBody.Messages[0].Content != "Hello, world!" {
t.Errorf("Expected message content to be 'Hello, world!'")
}
if len(reqBody.Files) != 0 {
t.Errorf("Expected no files, got %d", len(reqBody.Files))
}
// Verify request body
var reqBody ChatCompletionRequest
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
t.Errorf("Error decoding request body: %v", err)
}
// Return successful response
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`{"response": "Hello from Ollama!"}`)),
}, nil
},
}
},
expectedResult: `{"response": "Hello from Ollama!"}`,
expectError: false,
},
{
name: "Successful request with file",
userMessage: "Analyze this file",
fileID: "file123",
setupMock: func() *mockHTTPClient {
return &mockHTTPClient{
DoFunc: func(req *http.Request) (*http.Response, error) {
// Check request body to ensure file was included
body, _ := io.ReadAll(req.Body)
var reqBody ChatCompletionRequest
json.Unmarshal(body, &reqBody)
if len(reqBody.Files) != 1 || reqBody.Files[0].ID != "file123" {
t.Errorf("Expected file ID to be file123")
}
if reqBody.Model != "gemma3:12b" {
t.Errorf("Expected model 'gemma3:12b', got %s", reqBody.Model)
}
if len(reqBody.Messages) != 1 {
t.Errorf("Expected 1 message, got %d", len(reqBody.Messages))
}
if reqBody.Messages[0].Role != "user" {
t.Errorf("Expected role 'user', got %s", reqBody.Messages[0].Role)
}
// Return successful response
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`{"response": "File analysis complete"}`)),
}, nil
},
}
},
expectedResult: `{"response": "File analysis complete"}`,
expectError: false,
},
{
name: "HTTP error response",
userMessage: "Hello, world!",
fileID: "",
setupMock: func() *mockHTTPClient {
return &mockHTTPClient{
DoFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(bytes.NewBufferString(`{"error": "Bad request"}`)),
}, nil
},
}
},
expectedResult: "",
expectError: true,
},
}
// Check if fileID was properly included
if r.URL.Query().Get("fileId") == "test-file" {
if len(reqBody.Files) != 1 {
t.Errorf("Expected 1 file, got %d", len(reqBody.Files))
}
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Create mock client
mockClient := tc.setupMock()
// Create a custom HTTP client and replace the default
client := &http.Client{
Transport: &mockTransport{mockClient: mockClient},
}
// Initialize ollama with test key
ollama := &Ollama{OllamaKey: "test-key"}
// Mock response.RespondWithError to prevent nil pointer dereference
originalRespondWithError := response.RespondWithError
response.RespondWithError = func(w http.ResponseWriter, r *http.Request, status int, message string, err error) {}
defer func() { response.RespondWithError = originalRespondWithError }()
// Call the function
result, err := ollama.SendRequest(tc.userMessage, tc.fileID)
// Check results
if tc.expectError && err == nil {
t.Error("Expected an error but got none")
}
if !tc.expectError && err != nil {
t.Errorf("Did not expect an error but got: %v", err)
}
if !tc.expectError && result != tc.expectedResult {
t.Errorf("Expected result %q, got %q", tc.expectedResult, result)
}
})
}
// Return mock response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"response": "This is a test response"}`))
}))
defer server.Close()
// Create Ollama client with mock server URL
ollama := &Ollama{
OllamaKey: "test-key",
OllamaEndpoint: server.URL,
}
// Test without fileID
resp, err := ollama.SendRequest("Hello", "")
if err != nil {
t.Errorf("SendRequest failed: %v", err)
}
if resp != `{"response": "This is a test response"}` {
t.Errorf("Unexpected response: %s", resp)
}
// Test with fileID
resp, err = ollama.SendRequest("Hello with file", "test-file")
if err != nil {
t.Errorf("SendRequest with fileID failed: %v", err)
}
}
// Custom transport that uses our mockClient
type mockTransport struct {
mockClient *mockHTTPClient
func TestSendRequestError(t *testing.T) {
// Create mock server that returns an error
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal Server Error"))
}))
defer server.Close()
// Create Ollama client with mock server URL
ollama := &Ollama{
OllamaKey: "test-key",
OllamaEndpoint: server.URL,
}
// Test error handling
_, err := ollama.SendRequest("Hello", "")
if err == nil {
t.Error("Expected error, got nil")
}
}
func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return m.mockClient.Do(req)
// TestOllamaRequestHandlerIntegration tests the complete flow of the OllamaRequest handler
func TestOllamaRequestHandlerIntegration(t *testing.T) {
// Save original NewOllama function and restore it after the test
originalNewOllama := NewOllama
defer func() {
NewOllama = originalNewOllama
}()
// Set up test cases
testCases := []struct {
name string
requestBody string
mockResponse string
mockError error
expectedStatus int
expectedBody string
}{
{
name: "Successful request",
requestBody: `{"message": "Hello, AI!", "fileId": "test-file"}`,
mockResponse: `{"choices":[{"message":{"content":"Hello, human!"}}]}`,
mockError: nil,
expectedStatus: http.StatusOK,
expectedBody: `{"response":"{\"choices\":[{\"message\":{\"content\":\"Hello, human!\"}}]}"}`,
},
{
name: "Invalid request body",
requestBody: `{"message": }`, // Invalid JSON
mockResponse: "",
mockError: nil,
expectedStatus: http.StatusBadRequest,
expectedBody: `{"error":"Invalid request body"}`,
},
{
name: "API Error",
requestBody: `{"message": "Hello"}`,
mockResponse: "",
mockError: fmt.Errorf("API error"),
expectedStatus: http.StatusInternalServerError,
expectedBody: `{"error":"Error sending request to Ollama API"}`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Override the NewOllama function to return our mock
NewOllama = func() *Ollama {
return &Ollama{
OllamaKey: "mock-key",
OllamaEndpoint: "mock-endpoint",
}
}
// Create a mock SendRequest function
originalSendRequest := (*Ollama).SendRequest
defer func() {
(*Ollama).SendRequest = originalSendRequest
}()
(*Ollama).SendRequest = func(o *Ollama, userMessage string, fileID string) (string, error) {
return tc.mockResponse, tc.mockError
}
// Create a request with the test case body
req := httptest.NewRequest(http.MethodPost, "/api/ollama", strings.NewReader(tc.requestBody))
req.Header.Set("Content-Type", "application/json")
// Create a recorder to capture the response
w := httptest.NewRecorder()
// Call the handler
OllamaRequest(w, req)
// Get the result
resp := w.Result()
defer resp.Body.Close()
// Check the status code
if resp.StatusCode != tc.expectedStatus {
t.Errorf("Expected status %d, got %d", tc.expectedStatus, resp.StatusCode)
}
// Check the response body
var respBody map[string]string
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
t.Fatalf("Failed to decode response body: %v", err)
}
// For successful case, verify the response contains the expected content
if tc.expectedStatus == http.StatusOK {
if _, ok := respBody["response"]; !ok {
t.Errorf("Response doesn't contain 'response' field")
}
} else {
// For error cases, check if error message is included
if _, ok := respBody["error"]; !ok {
t.Errorf("Error response doesn't contain 'error' field")
}
}
})
}
}
// TestOllamaRequest tests the OllamaRequest HTTP handler
func TestOllamaRequest(t *testing.T) {
tests := []struct {
name string
requestBody string
setupMock func() (*Ollama, func())
expectedStatusCode int
expectedResponse string
}{
{
name: "Valid request",
requestBody: `{"message": "Hello", "fileId": ""}`,
setupMock: func() (*Ollama, func()) {
// Create a mock Ollama that returns a successful response
mockOllama := &Ollama{}
// Save the original NewOllama function and replace it
originalNewOllama := NewOllama
NewOllama = func() *Ollama {
return mockOllama
}
// Save the original SendRequest method and replace it
originalSendRequest := mockOllama.SendRequest
mockOllama.SendRequest = func(userMessage, fileID string) (string, error) {
return `{"content": "Hello from Ollama!"}`, nil
}
// Return the mock and a cleanup function
return mockOllama, func() {
NewOllama = originalNewOllama
mockOllama.SendRequest = originalSendRequest
}
},
expectedStatusCode: http.StatusOK,
expectedResponse: `{"response":{"content": "Hello from Ollama!"}}`,
},
{
name: "Invalid JSON request",
requestBody: `{"message": "Hello"`, // Missing closing brace
setupMock: func() (*Ollama, func()) {
mockOllama := &Ollama{}
originalNewOllama := NewOllama
NewOllama = func() *Ollama {
return mockOllama
}
return mockOllama, func() {
NewOllama = originalNewOllama
}
},
expectedStatusCode: http.StatusBadRequest,
expectedResponse: "", // We don't care about the exact error message
},
{
name: "API error",
requestBody: `{"message": "Error", "fileId": ""}`,
setupMock: func() (*Ollama, func()) {
mockOllama := &Ollama{}
originalNewOllama := NewOllama
NewOllama = func() *Ollama {
return mockOllama
}
originalSendRequest := mockOllama.SendRequest
mockOllama.SendRequest = func(userMessage, fileID string) (string, error) {
return "", &httpError{message: "API error", statusCode: http.StatusInternalServerError}
}
return mockOllama, func() {
NewOllama = originalNewOllama
mockOllama.SendRequest = originalSendRequest
}
},
expectedStatusCode: http.StatusInternalServerError,
expectedResponse: "", // We don't care about the exact error message
},
}
// TestEndToEndIntegration tests the actual API connection if environment variables are set
func TestEndToEndIntegration(t *testing.T) {
// Skip this test unless explicitly enabled with an environment variable
if os.Getenv("ENABLE_E2E_TESTS") != "true" {
t.Skip("Skipping end-to-end test; set ENABLE_E2E_TESTS=true to run")
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup mock
_, cleanup := tc.setupMock()
defer cleanup()
// Create a real request
reqBody := map[string]string{
"message": "Hello, this is a test message",
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
t.Fatalf("Failed to marshal request body: %v", err)
}
// Create request and response recorder
req := httptest.NewRequest("POST", "/api/ollama", strings.NewReader(tc.requestBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Create a request to our handler
req := httptest.NewRequest(http.MethodPost, "/api/ollama", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
// Call the handler
OllamaRequest(w, req)
// Create a recorder
w := httptest.NewRecorder()
// Check status code
if w.Code != tc.expectedStatusCode {
t.Errorf("Expected status code %d, got %d", tc.expectedStatusCode, w.Code)
}
// Call the actual handler (which will make a real API call)
OllamaRequest(w, req)
// For successful responses, check the response body
if tc.expectedStatusCode == http.StatusOK {
// Remove whitespace for comparison
expectedNoSpace := strings.ReplaceAll(tc.expectedResponse, " ", "")
actualNoSpace := strings.ReplaceAll(w.Body.String(), " ", "")
actualNoSpace = strings.ReplaceAll(actualNoSpace, "\n", "")
if expectedNoSpace != actualNoSpace {
t.Errorf("Expected response %q, got %q", tc.expectedResponse, w.Body.String())
}
}
})
}
// Check response
resp := w.Result()
defer resp.Body.Close()
// Basic assertions
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", resp.Status)
// Print error body for debugging
var errorResp map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errorResp); err == nil {
t.Logf("Error response: %v", errorResp)
}
} else {
var respBody map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
t.Errorf("Failed to decode response body: %v", err)
} else {
t.Logf("Successful API response received: %v", respBody)
}
}
}
// Helper error type for testing
type httpError struct {
message string
statusCode int
}
func (e *httpError) Error() string {
return e.message
}

View File

@@ -9,7 +9,9 @@ import (
"net/http"
"os"
"gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/logger"
"gitea.miguelmuniz.com/rogueking/junk2jive-server/internal/response"
"github.com/joho/godotenv"
)
// ImageRequest represents the incoming JSON request with image data
@@ -22,10 +24,6 @@ type RoboflowResponse struct {
Predictions []struct {
Class string `json:"class"`
Confidence float64 `json:"confidence"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
} `json:"predictions"`
}
@@ -34,34 +32,46 @@ type Service struct {
apiKey string
}
func init() {
// Load .env file if it exists
err := godotenv.Load()
if err != nil {
logger.Debug("No .env file found or error loading it: ", err)
} else {
logger.Debug(".env file loaded successfully")
}
}
// NewService creates a new Roboflow service instance
func NewService() *Service {
apiKey := os.Getenv("ROBOFLOW_API_KEY")
if apiKey == "" {
logger.Error("ROBOFLOW_API_KEY environment variable not set")
}
return &Service{
apiKey: os.Getenv("ROBOFLOW_API_KEY"),
apiKey: apiKey,
}
}
// HandleImageRequest processes the JSON image request
func HandleImageRequest(w http.ResponseWriter, r *http.Request) {
// Decode the JSON request
var req ImageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.RespondWithError(w, r, http.StatusBadRequest, "Invalid JSON request: %v", err)
http.Error(w, "Invalid JSON request: "+err.Error(), http.StatusBadRequest)
response.RespondWithError(w, r, http.StatusBadRequest, "Invalid JSON request", err)
return
}
// Check if image was provided
if err := json.Unmarshal([]byte(req.Image), &req); err != nil {
response.RespondWithError(w, r, http.StatusBadRequest, "No image provided in request", err)
if req.Image == "" {
response.RespondWithError(w, r, http.StatusBadRequest, "No image provided in request", nil)
return
}
// Decode base64 image
imageData, err := base64.StdEncoding.DecodeString(req.Image)
if err != nil {
response.RespondWithError(w, r, http.StatusBadRequest, "Invalid base64 image: %v ", err)
response.RespondWithError(w, r, http.StatusBadRequest, "Invalid base64 image", err)
return
}
@@ -70,7 +80,7 @@ func HandleImageRequest(w http.ResponseWriter, r *http.Request) {
// Process the image with Roboflow
responser, err := service.AnalyzeImage(imageData)
if err != nil {
response.RespondWithError(w, r, http.StatusInternalServerError, "Error processing image: %v", err)
response.RespondWithError(w, r, http.StatusInternalServerError, "Error processing image", err)
return
}

View File

@@ -0,0 +1,260 @@
package roboflow
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewService(t *testing.T) {
// Save original env and restore after test
originalAPIKey := os.Getenv("ROBOFLOW_API_KEY")
defer os.Setenv("ROBOFLOW_API_KEY", originalAPIKey)
// Set test API key
testAPIKey := "test-api-key"
os.Setenv("ROBOFLOW_API_KEY", testAPIKey)
// Create new service
service := NewService()
// Check if API key is set correctly
if service.apiKey != testAPIKey {
t.Errorf("Expected apiKey to be %s, got %s", testAPIKey, service.apiKey)
}
}
func TestGetDetectedObjects(t *testing.T) {
// Create test response
response := &RoboflowResponse{
Predictions: []struct {
Class string `json:"class"`
Confidence float64 `json:"confidence"`
}{
{Class: "bottle", Confidence: 0.95},
{Class: "cup", Confidence: 0.85},
},
}
// Get detected objects
objects := GetDetectedObjects(response)
// Check if objects are extracted correctly
expected := []string{"bottle", "cup"}
if !reflect.DeepEqual(objects, expected) {
t.Errorf("Expected objects %v, got %v", expected, objects)
}
}
// MockHTTPClient allows us to mock HTTP responses
type MockHTTPClient struct {
DoFunc func(req *http.Request) (*http.Response, error)
}
func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
return m.DoFunc(req)
}
func TestAnalyzeImage(t *testing.T) {
// Setup
originalClient := http.DefaultClient
defer func() { http.DefaultClient = originalClient }()
// Test cases
testCases := []struct {
name string
responseStatus int
responseBody string
expectedError bool
expectedResult *RoboflowResponse
}{
{
name: "Successful API response",
responseStatus: http.StatusOK,
responseBody: `{"predictions":[{"class":"bottle","confidence":0.95}]}`,
expectedError: false,
expectedResult: &RoboflowResponse{
Predictions: []struct {
Class string `json:"class"`
Confidence float64 `json:"confidence"`
}{
{Class: "bottle", Confidence: 0.95},
},
},
},
{
name: "API error response",
responseStatus: http.StatusInternalServerError,
responseBody: `{"error":"Internal server error"}`,
expectedError: true,
expectedResult: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Mock HTTP client
mockClient := &http.Client{
Transport: &mockTransport{
response: &http.Response{
StatusCode: tc.responseStatus,
Body: io.NopCloser(strings.NewReader(tc.responseBody)),
Header: make(http.Header),
},
},
}
http.DefaultClient = mockClient
service := &Service{apiKey: "api-key"}
result, err := service.AnalyzeImage([]byte("test-image-data"))
// Check error
if tc.expectedError && err == nil {
t.Error("Expected error but got nil")
}
if !tc.expectedError && err != nil {
t.Errorf("Expected no error but got: %v", err)
}
// Check result
if !tc.expectedError {
if result == nil {
t.Error("Expected result but got nil")
} else if len(result.Predictions) != len(tc.expectedResult.Predictions) {
t.Errorf("Expected %d predictions, got %d",
len(tc.expectedResult.Predictions),
len(result.Predictions))
} else {
for i, pred := range result.Predictions {
expected := tc.expectedResult.Predictions[i]
if pred.Class != expected.Class || pred.Confidence != expected.Confidence {
t.Errorf("Expected prediction %v, got %v", expected, pred)
}
}
}
}
})
}
}
// mockTransport is a mock implementation of RoundTripper
type mockTransport struct {
response *http.Response
}
func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return m.response, nil
}
func TestHandleImageRequest(t *testing.T) {
// Test cases
testCases := []struct {
name string
requestBody string
expectedStatus int
}{
{
name: "Valid request",
requestBody: `{"image":"dGVzdC1pbWFnZS1kYXRh"}`, // Base64 of "test-image-data"
expectedStatus: http.StatusOK,
},
{
name: "Invalid JSON",
requestBody: `{invalid-json}`,
expectedStatus: http.StatusBadRequest,
},
{
name: "Empty image",
requestBody: `{"image":""}`,
expectedStatus: http.StatusBadRequest,
},
{
name: "Invalid base64",
requestBody: `{"image":"not-base64"}`,
expectedStatus: http.StatusBadRequest,
},
}
// Set up a mock for AnalyzeImage
var AnalyzeImage func(imageData []byte) (*RoboflowResponse, error)
originalAnalyzeImage := AnalyzeImage
defer func() {
AnalyzeImage = originalAnalyzeImage
}()
AnalyzeImage = func(imageData []byte) (*RoboflowResponse, error) {
return &RoboflowResponse{
Predictions: []struct {
Class string `json:"class"`
Confidence float64 `json:"confidence"`
}{
{Class: "test-class", Confidence: 0.95},
},
}, nil
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create request
req, err := http.NewRequest("POST", "/analyze", bytes.NewBufferString(tc.requestBody))
if err != nil {
t.Fatal(err)
}
// Create recorder
rr := httptest.NewRecorder()
// Call handler
handler := http.HandlerFunc(HandleImageRequest)
handler.ServeHTTP(rr, req)
// Check status
if status := rr.Code; status != tc.expectedStatus {
t.Errorf("Expected status %d, got %d", tc.expectedStatus, status)
}
})
}
}
func TestRoboflowIntegration(t *testing.T) {
if os.Getenv("ROBOFLOW_API_KEY") == "" {
t.Skip("Skipping test; ROBOFLOW_API_KEY not set")
}
t.Run("AnalyzeImage with valid image", func(t *testing.T) {
// Load a test image (you should place a suitable test image in testdata/)
imageData, err := os.ReadFile("/home/rogueking/Documents/junk2jive-server/test.png")
require.NoError(t, err, "Failed to load test image")
service := NewService()
resp, err := service.AnalyzeImage(imageData)
require.NoError(t, err, "AnalyzeImage failed")
require.NotNil(t, resp, "Response should not be nil")
assert.NotEmpty(t, resp.Predictions, "Expected some predictions")
})
}
// TestMain sets up and tears down test environment
func TestMain(m *testing.M) {
// Setup
originalAPIKey := os.Getenv("ROBOFLOW_API_KEY")
os.Setenv("ROBOFLOW_API_KEY", "")
// Run tests
code := m.Run()
// Teardown
os.Setenv("ROBOFLOW_API_KEY", originalAPIKey)
os.Exit(code)
}

View File

@@ -0,0 +1,153 @@
package router
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewRouter(t *testing.T) {
r := NewRouter()
assert.NotNil(t, r, "Router should not be nil")
assert.NotNil(t, r.router, "Chi router should not be nil")
assert.Nil(t, r.logger, "Logger should be nil initially")
assert.Nil(t, r.rateLimiter, "RateLimiter should be nil initially")
}
func TestSetupRouter(t *testing.T) {
origins := []string{"http://localhost:3000"}
r := SetupRouter(origins)
assert.NotNil(t, r, "Router should not be nil")
assert.NotNil(t, r.router, "Chi router should not be nil")
assert.NotNil(t, r.logger, "Logger should not be nil")
assert.NotNil(t, r.rateLimiter, "RateLimiter should not be nil")
}
func TestGetRouter(t *testing.T) {
r := NewRouter()
chiRouter := r.GetRouter()
assert.Equal(t, r.router, chiRouter, "GetRouter should return the router field")
}
func TestCoffeeEndpoint(t *testing.T) {
origins := []string{"http://localhost:3000"}
r := SetupRouter(origins)
// Create a test server
ts := httptest.NewServer(r.GetRouter())
defer ts.Close()
// Make request to the coffee endpoint
resp, err := http.Get(ts.URL + "/v1/api/coffee")
require.NoError(t, err, "Error making request to coffee endpoint")
defer resp.Body.Close()
// Check status code
assert.Equal(t, http.StatusTeapot, resp.StatusCode, "Should return teapot status")
// Check response body
var responseBody map[string]string
err = json.NewDecoder(resp.Body).Decode(&responseBody)
require.NoError(t, err, "Error decoding response body")
assert.Equal(t, "I'm A Teapot!", responseBody["error"], "Response should contain teapot message")
}
func TestCORSMiddleware(t *testing.T) {
testCases := []struct {
name string
origins []string
requestOrigin string
expectedHeader string
}{
{
name: "Allowed origin",
origins: []string{"http://allowed-origin.com"},
requestOrigin: "http://allowed-origin.com",
expectedHeader: "http://allowed-origin.com",
},
{
name: "Multiple allowed origins",
origins: []string{"http://origin1.com", "http://origin2.com"},
requestOrigin: "http://origin2.com",
expectedHeader: "http://origin2.com",
},
{
name: "Wildcard origin",
origins: []string{"*"},
requestOrigin: "http://any-origin.com",
expectedHeader: "*",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
r := SetupRouter(tc.origins)
req := httptest.NewRequest("OPTIONS", "/v1/api/coffee", nil)
req.Header.Set("Origin", tc.requestOrigin)
req.Header.Set("Access-Control-Request-Method", "GET")
rr := httptest.NewRecorder()
r.GetRouter().ServeHTTP(rr, req)
// For wildcard origin or matching origin, CORS headers should be set
if tc.origins[0] == "*" || contains(tc.origins, tc.requestOrigin) {
assert.Equal(t, tc.expectedHeader, rr.Header().Get("Access-Control-Allow-Origin"),
"CORS origin header should match expected value")
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Methods"), "GET",
"CORS methods should contain GET")
}
})
}
}
func TestRouterMiddlewares(t *testing.T) {
r := SetupRouter([]string{"http://localhost:3000"})
// Check if the router has middleware registered (indirect test)
// We can't easily test the actual middleware without mocking, but we can verify
// the router itself has routes registered
chiRouter := r.GetRouter()
routes := getAllRoutes(chiRouter)
assert.Contains(t, routes, "/v1/api/coffee", "Coffee endpoint should be registered")
assert.Contains(t, routes, "/v1/api/text", "Text endpoint should be registered")
assert.Contains(t, routes, "/v1/api/visual", "Visual endpoint should be registered")
}
// Helper functions
// contains checks if a slice contains a specific string
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// getAllRoutes is a helper to extract all routes from a chi router
func getAllRoutes(router *chi.Mux) []string {
var routes []string
// This is a simplified way to check for routes
// Chi doesn't expose routes directly, so this is a proxy check
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
routes = append(routes, route)
return nil
}
_ = chi.Walk(router, walkFunc)
return routes
}

View File

@@ -1,87 +0,0 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
type OpenAIService struct {
apiKey string
}
type OpenAIRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature float64 `json:"temperature"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type OpenAIResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
func NewOpenAIService(apiKey string) *OpenAIService {
return &OpenAIService{apiKey: apiKey}
}
func (s *OpenAIService) GenerateDIY(query string) (string, error) {
url := "https://api.openai.com/v1/chat/completions"
prompt := fmt.Sprintf("Generate creative DIY project ideas to repurpose or recycle the following: %s. Provide detailed instructions, materials needed, and steps to create each project.", query)
requestBody := OpenAIRequest{
Model: "gpt-4", // or "gpt-3.5-turbo" depending on your needs/access
Messages: []Message{
{
Role: "system",
Content: "You are a creative DIY expert who helps people repurpose and recycle items into useful projects.",
},
{
Role: "user",
Content: prompt,
},
},
Temperature: 0.7,
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return "", err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.apiKey))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var response OpenAIResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return "", err
}
if len(response.Choices) == 0 {
return "", fmt.Errorf("no response from OpenAI")
}
return response.Choices[0].Message.Content, nil
}

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
test2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB