| 1 | import 'package:flutter_test/flutter_test.dart'; |
| 2 | import 'package:localmind/core/models/enums.dart'; |
| 3 | import 'package:localmind/features/servers/data/models/server.dart'; |
| 4 | |
| 5 | void main() { |
| 6 | group('Server address parsing', () { |
| 7 | test('builds baseUrl for a bare host and port', () { |
| 8 | final server = Server( |
| 9 | id: '1', |
| 10 | name: 'Local', |
| 11 | type: ServerType.openAICompatible, |
| 12 | host: 'localhost', |
| 13 | port: 8080, |
| 14 | createdAt: DateTime(2026, 1, 1), |
| 15 | lastConnectedAt: DateTime(2026, 1, 1), |
| 16 | ); |
| 17 | |
| 18 | expect(server.baseUrl, 'http://localhost:8080'); |
| 19 | }); |
| 20 | |
| 21 | test('preserves an explicit http URL without doubling the port', () { |
| 22 | final server = Server( |
| 23 | id: '1', |
| 24 | name: 'Local', |
| 25 | type: ServerType.openAICompatible, |
| 26 | host: 'http://localhost:8080', |
| 27 | port: 1234, |
| 28 | createdAt: DateTime(2026, 1, 1), |
| 29 | lastConnectedAt: DateTime(2026, 1, 1), |
| 30 | ); |
| 31 | |
| 32 | expect(server.baseUrl, 'http://localhost:8080'); |
| 33 | expect(server.displayAddress, 'http://localhost:8080'); |
| 34 | }); |
| 35 | |
| 36 | test('accepts https URLs and keeps the configured port when missing', () { |
| 37 | final server = Server( |
| 38 | id: '1', |
| 39 | name: 'Secure', |
| 40 | type: ServerType.openAICompatible, |
| 41 | host: 'https://localhost', |
| 42 | port: 8443, |
| 43 | createdAt: DateTime(2026, 1, 1), |
| 44 | lastConnectedAt: DateTime(2026, 1, 1), |
| 45 | ); |
| 46 | |
| 47 | expect(server.baseUrl, 'https://localhost:8443'); |
| 48 | expect(server.displayAddress, 'https://localhost:8443'); |
| 49 | }); |
| 50 | |
| 51 | test('keeps a host that already includes a port', () { |
| 52 | final server = Server( |
| 53 | id: '1', |
| 54 | name: 'Local', |
| 55 | type: ServerType.openAICompatible, |
| 56 | host: 'localhost:8080', |
| 57 | port: 1234, |
| 58 | createdAt: DateTime(2026, 1, 1), |
| 59 | lastConnectedAt: DateTime(2026, 1, 1), |
| 60 | ); |
| 61 | |
| 62 | expect(server.baseUrl, 'http://localhost:8080'); |
| 63 | expect(server.displayAddress, 'localhost:8080'); |
| 64 | }); |
| 65 | |
| 66 | test('accepts a bare subdomain with no path', () { |
| 67 | final server = Server( |
| 68 | id: '1', |
| 69 | name: 'Remote Ollama', |
| 70 | type: ServerType.ollama, |
| 71 | host: 'ollama.example.com', |
| 72 | port: 11434, |
| 73 | createdAt: DateTime(2026, 1, 1), |
| 74 | lastConnectedAt: DateTime(2026, 1, 1), |
| 75 | ); |
| 76 | |
| 77 | expect(server.baseUrl, 'http://ollama.example.com:11434'); |
| 78 | expect(server.pathPrefix, isNull); |
| 79 | }); |
| 80 | |
| 81 | test('accepts https subdomain with no path', () { |
| 82 | final server = Server( |
| 83 | id: '1', |
| 84 | name: 'Secure Ollama', |
| 85 | type: ServerType.ollama, |
| 86 | host: 'https://ollama.example.com', |
| 87 | port: 11434, |
| 88 | createdAt: DateTime(2026, 1, 1), |
| 89 | lastConnectedAt: DateTime(2026, 1, 1), |
| 90 | ); |
| 91 | |
| 92 | expect(server.baseUrl, 'https://ollama.example.com:11434'); |
| 93 | expect(server.pathPrefix, isNull); |
| 94 | }); |
| 95 | |
| 96 | test( |
| 97 | 'Ollama server with explicit pathPrefix produces correct endpoints', |
| 98 | () { |
| 99 | final server = Server( |
| 100 | id: '1', |
| 101 | name: 'Reverse Proxy', |
| 102 | type: ServerType.ollama, |
| 103 | host: 'https://ai.example.com', |
| 104 | port: 11434, |
| 105 | pathPrefix: '/ollama', |
| 106 | createdAt: DateTime(2026, 1, 1), |
| 107 | lastConnectedAt: DateTime(2026, 1, 1), |
| 108 | ); |
| 109 | |
| 110 | expect(server.baseUrl, 'https://ai.example.com:11434'); |
| 111 | expect(server.pathPrefix, '/ollama'); |
| 112 | expect( |
| 113 | server.modelsEndpoint, |
| 114 | 'https://ai.example.com:11434/ollama/api/tags', |
| 115 | ); |
| 116 | expect( |
| 117 | server.chatEndpoint, |
| 118 | 'https://ai.example.com:11434/ollama/api/chat', |
| 119 | ); |
| 120 | }, |
| 121 | ); |
| 122 | |
| 123 | test('trailing slash in pathPrefix is normalized via apiPathPrefix', () { |
| 124 | final server = Server( |
| 125 | id: '1', |
| 126 | name: 'Trailing slash', |
| 127 | type: ServerType.ollama, |
| 128 | host: 'https://ai.example.com', |
| 129 | port: 11434, |
| 130 | pathPrefix: '/ollama/', |
| 131 | createdAt: DateTime(2026, 1, 1), |
| 132 | lastConnectedAt: DateTime(2026, 1, 1), |
| 133 | ); |
| 134 | |
| 135 | expect(server.apiPathPrefix, '/ollama'); |
| 136 | expect( |
| 137 | server.modelsEndpoint, |
| 138 | 'https://ai.example.com:11434/ollama/api/tags', |
| 139 | ); |
| 140 | }); |
| 141 | |
| 142 | test('root path becomes empty pathPrefix', () { |
| 143 | final server = Server( |
| 144 | id: '1', |
| 145 | name: 'Root path', |
| 146 | type: ServerType.ollama, |
| 147 | host: 'https://ai.example.com', |
| 148 | port: 11434, |
| 149 | pathPrefix: '', |
| 150 | createdAt: DateTime(2026, 1, 1), |
| 151 | lastConnectedAt: DateTime(2026, 1, 1), |
| 152 | ); |
| 153 | |
| 154 | expect(server.pathPrefix, ''); |
| 155 | expect(server.baseUrl, 'https://ai.example.com:11434'); |
| 156 | expect(server.modelsEndpoint, 'https://ai.example.com:11434/api/tags'); |
| 157 | }); |
| 158 | |
| 159 | test('explicit port in host overrides default port', () { |
| 160 | final server = Server( |
| 161 | id: '1', |
| 162 | name: 'Custom port', |
| 163 | type: ServerType.ollama, |
| 164 | host: 'https://ai.example.com:8443', |
| 165 | port: 11434, |
| 166 | pathPrefix: '/ollama', |
| 167 | createdAt: DateTime(2026, 1, 1), |
| 168 | lastConnectedAt: DateTime(2026, 1, 1), |
| 169 | ); |
| 170 | |
| 171 | expect(server.baseUrl, 'https://ai.example.com:8443'); |
| 172 | expect(server.pathPrefix, '/ollama'); |
| 173 | }); |
| 174 | |
| 175 | test('OpenAI-compatible with pathPrefix produces correct endpoints', () { |
| 176 | final server = Server( |
| 177 | id: '1', |
| 178 | name: 'OpenAI proxy', |
| 179 | type: ServerType.openAICompatible, |
| 180 | host: 'https://ai.example.com', |
| 181 | port: 8080, |
| 182 | pathPrefix: '/openai', |
| 183 | createdAt: DateTime(2026, 1, 1), |
| 184 | lastConnectedAt: DateTime(2026, 1, 1), |
| 185 | ); |
| 186 | |
| 187 | expect(server.baseUrl, 'https://ai.example.com:8080'); |
| 188 | expect(server.pathPrefix, '/openai'); |
| 189 | expect( |
| 190 | server.modelsEndpoint, |
| 191 | 'https://ai.example.com:8080/openai/v1/models', |
| 192 | ); |
| 193 | expect( |
| 194 | server.chatEndpoint, |
| 195 | 'https://ai.example.com:8080/openai/v1/chat/completions', |
| 196 | ); |
| 197 | expect(server.displayAddress, 'https://ai.example.com:8080/openai'); |
| 198 | }); |
| 199 | |
| 200 | test('OpenAI-compatible with /v1 pathPrefix avoids double /v1', () { |
| 201 | final server = Server( |
| 202 | id: '1', |
| 203 | name: 'OpenAI direct v1', |
| 204 | type: ServerType.openAICompatible, |
| 205 | host: 'https://ai.example.com', |
| 206 | port: 8080, |
| 207 | pathPrefix: '/v1', |
| 208 | createdAt: DateTime(2026, 1, 1), |
| 209 | lastConnectedAt: DateTime(2026, 1, 1), |
| 210 | ); |
| 211 | |
| 212 | expect(server.modelsEndpoint, 'https://ai.example.com:8080/v1/models'); |
| 213 | expect( |
| 214 | server.chatEndpoint, |
| 215 | 'https://ai.example.com:8080/v1/chat/completions', |
| 216 | ); |
| 217 | }); |
| 218 | |
| 219 | test( |
| 220 | 'OpenAI-compatible with nested /proxy/v1 pathPrefix avoids double /v1', |
| 221 | () { |
| 222 | final server = Server( |
| 223 | id: '1', |
| 224 | name: 'OpenAI nested proxy', |
| 225 | type: ServerType.openAICompatible, |
| 226 | host: 'https://ai.example.com', |
| 227 | port: 8080, |
| 228 | pathPrefix: '/proxy/v1', |
| 229 | createdAt: DateTime(2026, 1, 1), |
| 230 | lastConnectedAt: DateTime(2026, 1, 1), |
| 231 | ); |
| 232 | |
| 233 | expect( |
| 234 | server.modelsEndpoint, |
| 235 | 'https://ai.example.com:8080/proxy/v1/models', |
| 236 | ); |
| 237 | expect( |
| 238 | server.chatEndpoint, |
| 239 | 'https://ai.example.com:8080/proxy/v1/chat/completions', |
| 240 | ); |
| 241 | }, |
| 242 | ); |
| 243 | |
| 244 | test('LM Studio with pathPrefix produces correct endpoints', () { |
| 245 | final server = Server( |
| 246 | id: '1', |
| 247 | name: 'LM Studio proxy', |
| 248 | type: ServerType.lmStudio, |
| 249 | host: 'https://ai.example.com', |
| 250 | port: 1234, |
| 251 | pathPrefix: '/lmstudio', |
| 252 | createdAt: DateTime(2026, 1, 1), |
| 253 | lastConnectedAt: DateTime(2026, 1, 1), |
| 254 | ); |
| 255 | |
| 256 | expect( |
| 257 | server.modelsEndpoint, |
| 258 | 'https://ai.example.com:1234/lmstudio/api/v1/models', |
| 259 | ); |
| 260 | expect( |
| 261 | server.chatEndpoint, |
| 262 | 'https://ai.example.com:1234/lmstudio/api/v1/chat', |
| 263 | ); |
| 264 | expect(server.displayAddress, 'https://ai.example.com:1234/lmstudio'); |
| 265 | }); |
| 266 | |
| 267 | test('rejects URL with query string', () { |
| 268 | expect(parseServerAddressInput('https://ai.example.com?foo=bar'), isNull); |
| 269 | }); |
| 270 | |
| 271 | test('rejects URL with fragment', () { |
| 272 | expect(parseServerAddressInput('https://ai.example.com#frag'), isNull); |
| 273 | }); |
| 274 | |
| 275 | test('normalizeServerPathPrefix converts root slash to empty', () { |
| 276 | expect(normalizeServerPathPrefix('/'), ''); |
| 277 | }); |
| 278 | |
| 279 | test('normalizeServerPathPrefix strips trailing slash', () { |
| 280 | expect(normalizeServerPathPrefix('/ollama/'), '/ollama'); |
| 281 | }); |
| 282 | |
| 283 | test('extractPathPrefix returns empty for no path', () { |
| 284 | final uri = Uri.parse('https://example.com'); |
| 285 | expect(extractPathPrefix(uri), ''); |
| 286 | }); |
| 287 | |
| 288 | test('extractPathPrefix returns empty for root slash', () { |
| 289 | final uri = Uri.parse('https://example.com/'); |
| 290 | expect(extractPathPrefix(uri), ''); |
| 291 | }); |
| 292 | |
| 293 | test('extractPathPrefix returns path for valid prefix', () { |
| 294 | final uri = Uri.parse('https://example.com/ollama'); |
| 295 | expect(extractPathPrefix(uri), '/ollama'); |
| 296 | }); |
| 297 | |
| 298 | test('local IP still works as before', () { |
| 299 | final server = Server( |
| 300 | id: '1', |
| 301 | name: 'Local', |
| 302 | type: ServerType.openAICompatible, |
| 303 | host: '192.168.1.100', |
| 304 | port: 8080, |
| 305 | createdAt: DateTime(2026, 1, 1), |
| 306 | lastConnectedAt: DateTime(2026, 1, 1), |
| 307 | ); |
| 308 | |
| 309 | expect(server.baseUrl, 'http://192.168.1.100:8080'); |
| 310 | expect(server.pathPrefix, isNull); |
| 311 | }); |
| 312 | |
| 313 | test('existing bare host without port still works', () { |
| 314 | final server = Server( |
| 315 | id: '1', |
| 316 | name: 'Local', |
| 317 | type: ServerType.openAICompatible, |
| 318 | host: 'localhost', |
| 319 | port: 8080, |
| 320 | createdAt: DateTime(2026, 1, 1), |
| 321 | lastConnectedAt: DateTime(2026, 1, 1), |
| 322 | ); |
| 323 | |
| 324 | expect(server.baseUrl, 'http://localhost:8080'); |
| 325 | }); |
| 326 | }); |
| 327 | } |